[
  {
    "path": ".dockerignore",
    "content": ".env\n.dockerignore\nspec.yaml\n**/target\ntarget/\ndeploy/\ntests/\ndocker/Dockerfile\nscripts/\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "ko_fi: appflowy\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[Bug] Untitled Bug Issue\"\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Additional context**\n - Environment [e.g. flutter doctor -v or rustup show]\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[FR] Untitled Feature Request\"\nlabels: ''\nassignees: ''\n\n---\n\n**1~3 main use cases of the proposed feature**\nEx: As a ... , I want to set a reminder for a checkbox item so that I can be reminded by the system at a specific time.\n\n**what types of users can benefit from using your proposed feature**\nEx: busy students who tend to forget their paper deadlines\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/audit.yml",
    "content": "name: Security audit\non:\n  schedule:\n    - cron: '0 0 * * *'\n  push:\n    paths:\n      - '**/Cargo.toml'\n      - '**/Cargo.lock'\njobs:\n  security_audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: taiki-e/install-action@cargo-deny\n      - name: Scan for vulnerabilities\n        run:\n          cargo deny check advisories"
  },
  {
    "path": ".github/workflows/build_test_docker_image.yaml",
    "content": "name: Manually Build Selected Docker Images\n\non:\n  workflow_dispatch:\n    inputs:\n      branch:\n        description: \"Branch to build from\"\n        required: true\n        default: \"main\"\n      debug_version:\n        description: \"Enter the release version tag (e.g. 0.9.30). The built image will be tagged as xxx:0.9.30-amd64.\"\n        required: true\n      archs:\n        description: \"Target architectures (comma separated), e.g. linux/amd64,linux/arm64\"\n        required: false\n        default: \"linux/amd64\"\n      build_gotrue:\n        description: \"Build GoTrue image\"\n        type: boolean\n        required: false\n        default: false\n      build_appflowy_cloud:\n        description: \"Build AppFlowy Cloud image\"\n        type: boolean\n        required: false\n        default: true\n      build_admin_frontend:\n        description: \"Build Admin Frontend image\"\n        type: boolean\n        required: false\n        default: false\n      build_appflowy_worker:\n        description: \"Build AppFlowy Worker image\"\n        type: boolean\n        required: false\n        default: true\n\njobs:\n  setup:\n    runs-on: ubuntu-22.04\n    outputs:\n      matrix: ${{ steps.set-matrix.outputs.matrix }}\n    steps:\n      - name: Set up architecture matrix\n        id: set-matrix\n        run: |\n          # Convert comma-separated archs to JSON array and extract arch names\n          archs=\"${{ github.event.inputs.archs }}\"\n          # Replace linux/ prefix and convert to JSON array\n          matrix_archs=$(echo \"$archs\" | sed 's/linux\\///g' | sed 's/,/\",\"/g' | sed 's/^/[\"/' | sed 's/$/\"]/')\n          echo \"matrix={\\\"arch\\\":$matrix_archs}\" >> $GITHUB_OUTPUT\n          echo \"Generated matrix: {\\\"arch\\\":$matrix_archs}\"\n\n  gotrue:\n    runs-on: ubuntu-22.04\n    needs: setup\n    if: ${{ github.event.inputs.build_gotrue == 'true' }}\n    strategy:\n      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n        with:\n          ref: ${{ github.event.inputs.branch }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Build and push GoTrue image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./docker/gotrue/Dockerfile\n          platforms: linux/${{ matrix.arch }}\n          push: true\n          tags: |\n            appflowyinc/gotrue:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  appflowy_cloud:\n    runs-on: ubuntu-22.04\n    needs: setup\n    if: ${{ github.event.inputs.build_appflowy_cloud == 'true' }}\n    strategy:\n      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n        with:\n          ref: ${{ github.event.inputs.branch }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Build and push AppFlowy Cloud image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          platforms: linux/${{ matrix.arch }}\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_cloud:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal\n          provenance: false\n          build-args: |\n            PROFILE=release\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  admin_frontend:\n    runs-on: ubuntu-22.04\n    needs: setup\n    if: ${{ github.event.inputs.build_admin_frontend == 'true' }}\n    strategy:\n      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n        with:\n          ref: ${{ github.event.inputs.branch }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Build and push Admin Frontend image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./admin_frontend/Dockerfile\n          platforms: linux/${{ matrix.arch }}\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_HUB_USERNAME }}/admin_frontend:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal\n          provenance: false\n          build-args: |\n            PROFILE=release\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  appflowy_worker:\n    runs-on: ubuntu-22.04\n    needs: setup\n    if: ${{ github.event.inputs.build_appflowy_worker == 'true' }}\n    strategy:\n      matrix: ${{ fromJson(needs.setup.outputs.matrix) }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v3\n        with:\n          ref: ${{ github.event.inputs.branch }}\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Build and push AppFlowy Worker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./services/appflowy-worker/Dockerfile\n          platforms: linux/${{ matrix.arch }}\n          push: true\n          tags: |\n            ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_worker:${{ github.event.inputs.debug_version }}-${{ matrix.arch }}-internal\n          provenance: false\n          build-args: |\n            PROFILE=release\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout"
  },
  {
    "path": ".github/workflows/client_api_check.yml",
    "content": "name: ClientAPI Check\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    types: [ opened, synchronize, reopened ]\n    branches: [ main ]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: |\n            AppFlowy-Cloud\n\n      - name: Install cargo-tree\n        run: cargo install cargo-tree\n\n      - name: Install wasm-pack\n        run: cargo install wasm-pack\n\n      - name: install prerequisites\n        run: |\n          sudo apt-get update\n          sudo apt-get install protobuf-compiler\n\n      - name: Build ClientAPI\n        working-directory: ./libs/client-api\n        run: cargo build --features \"enable_brotli\"\n\n      - name: Check ClientAPI Dependencies\n        working-directory: ./libs/client-api\n        run: bash ../../script/client_api_deps_check.sh\n\n"
  },
  {
    "path": ".github/workflows/commitlint.yml",
    "content": "name: Lint Commit Messages\non: [pull_request, push]\n\njobs:\n  commitlint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n        with:\n          fetch-depth: 0\n      - uses: wagoid/commitlint-github-action@v4\n\n"
  },
  {
    "path": ".github/workflows/integration_test.yml",
    "content": "name: AppFlowy-Cloud Integrations\n\non:\n  push:\n    branches: [ main ]\n    paths:\n      - 'src/**'\n      - 'libs/**'\n      - 'services/**'\n      - 'admin_frontend/**'\n  pull_request:\n    branches: [ main ]\n    paths:\n      - 'src/**'\n      - 'libs/**'\n      - 'services/**'\n      - 'admin_frontend/**'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\nenv:\n  LOCALHOST_URL: http://localhost\n  LOCALHOST_WS: ws://localhost/ws/v1\n  LOCALHOST_WS_V2: ws://localhost/ws/v2\n  APPFLOWY_REDIS_URI: redis://redis:6379\n  APPFLOWY_AI_REDIS_URL: redis://redis:6379\n  LOCALHOST_GOTRUE: http://localhost/gotrue\n  POSTGRES_PASSWORD: password\n  DATABASE_URL: postgres://postgres:password@localhost:5432/postgres\n  SQLX_OFFLINE: true\n  RUST_TOOLCHAIN: \"1.86.0\"\n  APPFLOWY_AI_VERSION: \"0.9.38-amd64\"\n\njobs:\n  setup:\n    name: Setup Environment and Build Images\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install prerequisites\n        run: |\n          sudo apt-get update\n          sudo apt-get install protobuf-compiler\n          sudo update-ca-certificates\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n        with:\n          driver-opts: network=host\n\n      - name: Build Docker Images\n        run: |\n          export DOCKER_DEFAULT_PLATFORM=linux/amd64\n          cp deploy.env .env\n          docker compose build \\\n            --parallel \\\n            --build-arg BUILDKIT_INLINE_CACHE=1 \\\n            --build-arg PROFILE=debug \\\n            appflowy_cloud appflowy_worker admin_frontend\n\n      - name: Save Docker Images\n        run: |\n          docker save appflowyinc/appflowy_cloud:latest | gzip > appflowy_cloud.tar.gz\n          docker save appflowyinc/appflowy_worker:latest | gzip > appflowy_worker.tar.gz\n          docker save appflowyinc/admin_frontend:latest | gzip > admin_frontend.tar.gz\n\n      - name: Upload Docker Images as Artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: docker-images\n          path: |\n            appflowy_cloud.tar.gz\n            appflowy_worker.tar.gz\n            admin_frontend.tar.gz\n          retention-days: 1\n\n  test:\n    name: Integration Tests\n    runs-on: ubuntu-latest\n    needs: setup\n    timeout-minutes: 60\n    strategy:\n      matrix:\n        include:\n          - test_service: \"appflowy_cloud\"\n            test_cmd: \"--workspace --exclude appflowy-ai-client --features ai-test-enabled\"\n          - test_service: \"appflowy_cloud_new_sync\"\n            test_cmd: \"--features sync-v2 --test main collab\"\n          - test_service: \"appflowy_worker\"\n            test_cmd: \"-p appflowy-worker\"\n          - test_service: \"admin_frontend\"\n            test_cmd: \"-p admin_frontend\"\n    steps:\n      - uses: actions/checkout@v4\n      - uses: dtolnay/rust-toolchain@stable\n      - uses: Swatinem/rust-cache@v2\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          workspaces: \"AppFlowy-Cloud\"\n\n      - name: Download Docker Images\n        uses: actions/download-artifact@v4\n        with:\n          name: docker-images\n\n      - name: Load Docker Images\n        run: |\n          docker load < appflowy_cloud.tar.gz\n          docker load < appflowy_worker.tar.gz\n          docker load < admin_frontend.tar.gz\n\n      - name: Copy and rename deploy.env to .env\n        run: cp deploy.env .env\n\n      - name: Replace values in .env\n        run: |\n          # log level\n          sed -i 's|RUST_LOG=.*|RUST_LOG='appflowy_cloud=trace,appflowy_worker=trace,database=trace,indexer=trace'|' .env\n          sed -i 's|GOTRUE_SMTP_USER=.*|GOTRUE_SMTP_USER=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env\n          sed -i 's|GOTRUE_SMTP_PASS=.*|GOTRUE_SMTP_PASS=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env\n          sed -i 's|GOTRUE_SMTP_ADMIN_EMAIL=.*|GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.CI_GOTRUE_SMTP_ADMIN_EMAIL }}|' .env\n          sed -i 's|GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*|GOTRUE_EXTERNAL_GOOGLE_ENABLED=true|' .env\n          sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=false|' .env\n          sed -i 's|API_EXTERNAL_URL=http://your-host/gotrue|API_EXTERNAL_URL=http://localhost/gotrue|' .env\n          sed -i 's|GOTRUE_RATE_LIMIT_EMAIL_SENT=100|GOTRUE_RATE_LIMIT_EMAIL_SENT=1000|' .env\n          sed -i 's|APPFLOWY_MAILER_SMTP_USERNAME=.*|APPFLOWY_MAILER_SMTP_USERNAME=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env\n          sed -i 's|APPFLOWY_MAILER_SMTP_PASSWORD=.*|APPFLOWY_MAILER_SMTP_PASSWORD=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env\n          sed -i 's|AI_OPENAI_API_KEY=.*|AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env\n          sed -i 's|AI_OPENAI_API_SUMMARY_MODEL=.*|AI_OPENAI_API_SUMMARY_MODEL=\"gpt-4o-mini\"|' .env\n          sed -i 's|APPFLOWY_EMBEDDING_CHUNK_SIZE=.*|APPFLOWY_EMBEDDING_CHUNK_SIZE=500|' .env\n          sed -i 's|APPFLOWY_EMBEDDING_CHUNK_OVERLAP=.*|APPFLOWY_EMBEDDING_CHUNK_OVERLAP=50|' .env\n          sed -i 's|AI_ANTHROPIC_API_KEY=.*|AI_ANTHROPIC_API_KEY=${{ secrets.CI_AI_ANTHROPIC_API_KEY }}|' .env\n          sed -i 's|AI_APPFLOWY_HOST=.*|AI_APPFLOWY_HOST=http://localhost|' .env\n          sed -i 's|APPFLOWY_WEB_URL=.*|APPFLOWY_WEB_URL=http://localhost:3000|' .env\n          sed -i 's|.*APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=.*|APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=http://localhost/minio-api|' .env\n        shell: bash\n\n      - name: Update Nginx Configuration\n        # the wasm-pack headless tests will run on random ports, so we need to allow all origins\n        run: sed -i 's/http:\\/\\/127\\.0\\.0\\.1:8000/http:\\/\\/127.0.0.1/g' nginx/nginx.conf\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Run Docker-Compose\n        run: |\n          export APPFLOWY_WORKER_VERSION=latest\n          export APPFLOWY_CLOUD_VERSION=latest\n          export APPFLOWY_ADMIN_FRONTEND_VERSION=latest\n          export APPFLOWY_AI_VERSION=${{ env.APPFLOWY_AI_VERSION }}\n          docker compose -f docker-compose-ci.yml up -d\n          docker ps -a\n\n      - name: Wait for services to be ready\n        run: |\n          echo \"Waiting for services to be ready...\"\n          timeout 300 bash -c 'until curl -f http://localhost/health 2>/dev/null; do sleep 5; done' || echo \"Health check timeout - proceeding anyway\"\n\n      - name: Install prerequisites\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y protobuf-compiler\n\n      - name: Run Tests\n        run: |\n          echo \"Running tests for ${{ matrix.test_service }} with flags: ${{ matrix.test_cmd }}\"\n          RUST_LOG=\"info\" DISABLE_CI_TEST_LOG=\"true\" cargo test  ${{ matrix.test_cmd }} -- --skip stress_test\n\n      - name: Server Logs\n        if: failure()\n        run: |\n          docker ps -a\n          docker compose -f docker-compose-ci.yml logs\n\n      - name: AI Logs\n        if: failure()\n        run: |\n          docker logs appflowy-cloud-ai-1\n"
  },
  {
    "path": ".github/workflows/push_latest_docker.yml",
    "content": "name: DockerHub Build and Push\n\n#`DOCKER_HUB_USERNAME` is the username you use to log in to Docker Hub at https://hub.docker.com/. It's your Docker Hub\n# account username.\n\n#`DOCKER_HUB_ACCESS_TOKEN` is a security token that you should create in your Docker Hub account settings, specifically\n# under \"account settings / security.\" This token should be generated with read and write access permissions to Docker\n# Hub repositories. It allows you to authenticate and interact with Docker Hub programmatically, such as pushing and pulling Docker images or making API requests.\n\non:\n  push:\n    tags:\n      - 'v[0-9]+.[0-9]+.[0-9]+*' # Trigger for tags like v1.2.3\n      - '[0-9]+.[0-9]+.[0-9]+*'  # Trigger for tags like 1.2.3 or 1.2.3-alpha\n\nenv:\n  CARGO_TERM_COLOR: always\n  LATEST_TAG: latest\n\njobs:\n  gotrue_image:\n    runs-on: ubuntu-22.04\n    steps:\n      - name: Check out the repository\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Build and Push GoTrue\n        run: |\n          export TAG=${GITHUB_REF#refs/*/}\n          docker buildx build --platform linux/amd64,linux/arm64 -t appflowyinc/gotrue:${TAG} -t appflowyinc/gotrue:latest -f docker/gotrue/Dockerfile --push docker/gotrue\n\n  appflowy_cloud_image:\n    runs-on: ${{ matrix.job.os }}\n    env:\n      IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_cloud\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { os: \"ubuntu-22.04\", name: \"amd64\",   docker_platform: \"linux/amd64\" }\n          - { os: \"ubuntu-22.04-arm\", name: \"arm64v8\", docker_platform: \"linux/arm64\" }\n\n    steps:\n      - name: Check out the repository\n        uses: actions/checkout@v2\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: registry.hub.docker.com/${{ env.IMAGE_NAME }}\n\n      - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n        uses: docker/build-push-action@v5\n        with:\n          platforms: ${{ matrix.job.docker_platform }}\n          push: true\n          tags: |\n            ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }}\n            ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }}\n          labels: ${{ steps.meta.outputs.labels }}\n          provenance: false\n          build-args: |\n            PROFILE=release\n            FEATURES=\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  appflowy_cloud_docker_manifest:\n    runs-on: ubuntu-22.04\n    needs: [ appflowy_cloud_image ]\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { image_name: \"appflowy_cloud\" }\n\n    steps:\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:version\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8\n          push: true\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:latest\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8\n          push: true\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  admin_frontend_image:\n    runs-on: ${{ matrix.job.os }}\n    env:\n      IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/admin_frontend\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { os: \"ubuntu-22.04\", name: \"amd64\",   docker_platform: \"linux/amd64\" }\n          - { os: \"ubuntu-22.04-arm\", name: \"arm64v8\", docker_platform: \"linux/arm64\" }\n\n    steps:\n      - name: Check out the repository\n        uses: actions/checkout@v2\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: registry.hub.docker.com/${{ env.IMAGE_NAME }}\n\n      - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n        uses: docker/build-push-action@v5\n        with:\n          platforms: ${{ matrix.job.docker_platform }}\n          file: ./admin_frontend/Dockerfile\n          push: true\n          tags: |\n            ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }}\n            ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }}\n          labels: ${{ steps.meta.outputs.labels }}\n          provenance: false\n          build-args: |\n            PROFILE=release\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  admin_frontend_docker_manifest:\n    runs-on: ubuntu-22.04\n    needs: [ admin_frontend_image ]\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { image_name: \"admin_frontend\" }\n\n    steps:\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:version\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8\n          push: true\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:latest\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8\n          push: true\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  appflowy_worker_image:\n    runs-on: ${{ matrix.job.os }}\n    env:\n      IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_worker\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { os: \"ubuntu-22.04\", name: \"amd64\",   docker_platform: \"linux/amd64\" }\n          - { os: \"ubuntu-22.04-arm\", name: \"arm64v8\", docker_platform: \"linux/arm64\" }\n\n    steps:\n      - name: Check out the repository\n        uses: actions/checkout@v2\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: registry.hub.docker.com/${{ env.IMAGE_NAME }}\n\n      - name: Build and push ${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n        uses: docker/build-push-action@v5\n        with:\n          platforms: ${{ matrix.job.docker_platform }}\n          file: ./services/appflowy-worker/Dockerfile\n          push: true\n          tags: |\n            ${{ env.IMAGE_NAME }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }}\n            ${{ env.IMAGE_NAME }}:${{ env.GIT_TAG }}-${{ matrix.job.name }}\n          labels: ${{ steps.meta.outputs.labels }}\n          provenance: false\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n\n  appflowy_worker_manifest:\n    runs-on: ubuntu-22.04\n    needs: [ appflowy_worker_image ]\n    strategy:\n      fail-fast: false\n      matrix:\n        job:\n          - { image_name: \"appflowy_worker\" }\n\n    steps:\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Get git tag\n        id: vars\n        run: |\n          T=${GITHUB_REF#refs/*/}   # Remove \"refs/*/\" prefix from GITHUB_REF\n          echo \"GIT_TAG=$T\" >> $GITHUB_ENV\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:version\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.GIT_TAG }}-arm64v8\n          push: true\n\n      - name: Create and push manifest for ${{ matrix.job.image_name }}:latest\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}\n          images: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_HUB_USERNAME }}/${{ matrix.job.image_name }}:${{ env.LATEST_TAG }}-arm64v8\n          push: true\n\n      - name: Logout from Docker Hub\n        if: always()\n        run: docker logout\n"
  },
  {
    "path": ".github/workflows/rustlint.yml",
    "content": "name: Lint\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    types: [ opened, synchronize, reopened ]\n    branches: [ main ]\n\nenv:\n  SQLX_VERSION: 0.7.1\n  SQLX_FEATURES: \"rustls,postgres\"\n  SQLX_OFFLINE: true\n  RUST_TOOLCHAIN: \"1.86.0\"\n\njobs:\n  test:\n    name: fmt & clippy\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n          override: true\n          components: rustfmt, clippy\n          profile: minimal\n\n      - name: install prerequisites\n        run: |\n          sudo apt-get update\n          sudo apt-get install protobuf-compiler\n\n      - uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: |\n            AppFlowy-Cloud\n          key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}\n          restore-keys: |\n            ${{ runner.os }}-cargo-clippy-\n\n      - name: Copy and rename dev.env to .env\n        run: cp dev.env .env\n\n      - name: Code Gen\n        working-directory: ./script\n        run: ./code_gen.sh\n\n      - name: Rustfmt\n        run: |\n          cargo fmt --check\n\n      - name: Clippy\n        run: cargo clippy --all-targets --all-features --tests -- -D warnings\n"
  },
  {
    "path": ".github/workflows/stress_test.yml",
    "content": "name: AppFlowy-Cloud Stress Test\n\non: [ pull_request ]\n\nconcurrency:\n  group: stress-test-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\nenv:\n  SQLX_OFFLINE: true\n  RUST_TOOLCHAIN: \"1.86.0\"\n  POSTGRES_HOST: localhost\n  REDIS_HOST: localhost\n  LOCALHOST_GOTRUE: http://localhost/gotrue\n  DATABASE_URL: postgres://postgres:password@localhost:5432/postgres\n\njobs:\n  test:\n    name: Collab Stress Tests\n    runs-on: self-hosted-appflowy3\n\n    steps:\n      - name: Checkout Repository\n        uses: actions/checkout@v3\n\n      - name: Install Rust Toolchain\n        uses: dtolnay/rust-toolchain@stable\n\n      - name: Copy and Rename dev.env to .env\n        run: cp dev.env .env\n\n      - name: Install Prerequisites\n        run: |\n          brew update\n          if ! brew list libpq &>/dev/null; then\n            echo \"Installing libpq...\"\n            brew install libpq\n          else\n            echo \"libpq is already installed.\"\n          fi\n\n          if ! brew list sqlx-cli &>/dev/null; then\n            echo \"Installing sqlx-cli...\"\n            brew install sqlx-cli\n          else\n            echo \"sqlx-cli is already installed.\"\n          fi\n\n          if ! brew list protobuf &>/dev/null; then\n            echo \"Installing protobuf...\"\n            brew install protobuf\n          else\n            echo \"protobuf is already installed.\"\n          fi\n\n      - name: Replace Values in .env\n        run: |\n          sed -i '' 's|RUST_LOG=.*|RUST_LOG=debug|' .env\n          sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost:9999|' .env\n          sed -i '' 's|APPFLOWY_INDEXER_ENABLED=.*|APPFLOWY_INDEXER_ENABLED=false|' .env\n\n          sed -i '' 's|APPFLOWY_GOTRUE_BASE_URL=.*|APPFLOWY_GOTRUE_BASE_URL=http://localhost:9999|' .env\n          sed -i '' 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=false|' .env\n          sed -i '' 's|APPFLOWY_DATABASE_URL=.*|APPFLOWY_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres|' .env\n\n          cat .env\n        shell: bash\n\n      - name: Start Docker Compose Services\n        run: |\n          docker compose -f docker-compose-dev.yml down\n          docker compose -f docker-compose-dev.yml up -d\n          ./script/code_gen.sh\n          cargo sqlx database create && cargo sqlx migrate run\n\n      - name: Run Server and Test\n        run: |\n          cargo run --package xtask -- --stress-test\n"
  },
  {
    "path": ".github/workflows/wasm_publish.yml",
    "content": "name: Manual NPM Package Publish\n\non:\n  workflow_dispatch:\n    inputs:\n      working_directory:\n        description: 'Working directory (e.g., libs/client-api-wasm)'\n        required: true\n        default: 'libs/client-api-wasm'\n      package_name:\n        description: 'Which package to publish'\n        required: true\n        default: '@appflowyinc/client-api-wasm'\n        type: choice\n        options:\n          - '@appflowyinc/client-api-wasm'\n      package_version:\n        description: 'Package version'\n        required: true\n\nenv:\n  NODE_VERSION: '20.12.0'\n  RUST_TOOLCHAIN: \"1.86.0\"\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions-rs/toolchain@v1\n        with:\n          toolchain: ${{ env.RUST_TOOLCHAIN }}\n\n      - name: Setup Node.js\n        uses: actions/setup-node@v1\n        with:\n          node-version: ${{ env.NODE_VERSION }}\n\n      - uses: Swatinem/rust-cache@v2\n        with:\n          workspaces: |\n            AppFlowy-Cloud\n\n      - name: Install wasm-pack\n        run: cargo install wasm-pack\n\n      - name: Build with wasm-pack\n        run: wasm-pack build --release\n        working-directory: ${{ github.event.inputs.working_directory }}\n\n      - name: Update name\n        working-directory: ${{ github.event.inputs.working_directory }}/pkg\n        run: |\n          jq '.name = \"${{ github.event.inputs.package_name }}\"' package.json > package.json.tmp\n          mv package.json.tmp package.json\n      - name: Update version\n        working-directory: ${{ github.event.inputs.working_directory }}/pkg\n        run: |\n          npm version ${{ github.event.inputs.package_version }}\n\n      - name: Configure npm for wasm-pack\n        run: echo \"//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}\" > ${{ github.event.inputs.working_directory }}/pkg/.npmrc\n\n      - name: Publish package\n        run: |\n          npm config set access public\n          wasm-pack publish\n        working-directory: ${{ github.event.inputs.working_directory }}/pkg\n        env:\n          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/web_docker.yml",
    "content": "name: AppFlowy Web image build and push\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'AppFlowy Web version'\n        required: true\nenv:\n  IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/appflowy_web\njobs:\n  build:\n    runs-on: ${{ matrix.os }}\n    strategy:\n        matrix:\n          include:\n            - os: ubuntu-24.04\n              platform: linux/amd64\n            - os: ubuntu-24.04-arm\n              platform: linux/arm64\n    steps:\n      - name: Prepare\n        run: |\n          PLATFORM=${{ matrix.platform }}\n          VERSION=${{ github.event.inputs.version }}\n          IMAGE_TAG=${VERSION#v}\n          echo \"PLATFORM_PAIR=${PLATFORM//\\//-}\" >> $GITHUB_ENV\n          echo \"IMAGE_TAG=${IMAGE_TAG}\" >> $GITHUB_ENV\n\n      - name: Check out the repository\n        uses: actions/checkout@v3\n        with:\n          fetch-depth: 1\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Log in to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Build and push\n        id: build\n        uses: docker/build-push-action@v6\n        with:\n          platforms: ${{ matrix.platform }}\n          tags: |\n            ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-${{ env.PLATFORM_PAIR }}\n            ${{ env.IMAGE_NAME }}:latest-${{ env.PLATFORM_PAIR }}\n          build-args: VERSION=${{ github.event.inputs.version }}\n          context: docker/web\n          provenance: false\n          push: true\n  merge:\n    runs-on: ubuntu-24.04\n    needs:\n      - build\n    steps:\n      - name: Prepare\n        run: |\n          VERSION=${{ github.event.inputs.version }}\n          IMAGE_TAG=${VERSION#v}\n          echo \"IMAGE_TAG=${IMAGE_TAG}\" >> $GITHUB_ENV\n      - name: Login to Docker Hub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Create and push manifest\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}\n          images: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-linux-amd64,${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}-linux-arm64\n          push: true\n\n      - name: Create and push manifest\n        uses: Noelware/docker-manifest-action@0.4.3\n        with:\n          inputs: ${{ env.IMAGE_NAME }}:latest\n          images: ${{ env.IMAGE_NAME }}:latest-linux-amd64,${{ env.IMAGE_NAME }}:latest-linux-arm64\n          push: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n**/target/\n\n# These are backup files generated by rustfmt\n**/*.rs.bk\n.idea/\n**/temp/**\npackage-lock.json\nyarn.lock\nnode_modules\n**/libs/AppFlowy-Collab/\ndata/\n.env\n.logs\nflake.nix\nflake.lock\n.envrc\n.direnv/\n.claude\n\n**/.DS_Store\n**/.env.*\n\ndocker-compose.override.yml\n.serena\n"
  },
  {
    "path": ".sqlx/query-0389af6b225125d09c5a75b443561dba4d97b786d040e5b8d5a76de36716beb2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT oid\\n      FROM af_collab\\n      WHERE workspace_id = $1\\n        AND partition_key = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"0389af6b225125d09c5a75b443561dba4d97b786d040e5b8d5a76de36716beb2\"\n}\n"
  },
  {
    "path": ".sqlx/query-05e89f62ff993fa2e4b0002c0022bba9706359e402b07b15ccdeb67492625064.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT updated_at, blob\\n        FROM af_collab\\n        WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"05e89f62ff993fa2e4b0002c0022bba9706359e402b07b15ccdeb67492625064\"\n}\n"
  },
  {
    "path": ".sqlx/query-06096ba1131e78d3da5df25a4b0a1193f11c9782abaf91faf263a116f90e51af.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\\n        SELECT * FROM UNNEST($1::uuid[], $2::bytea[], $3::int[], $4::int[], $5::bigint[], $6::uuid[], $7::timestamp with time zone[])\\n        ON CONFLICT (oid)\\n        DO UPDATE SET blob = excluded.blob, len = excluded.len, updated_at = excluded.updated_at where af_collab.workspace_id = excluded.workspace_id\\n      \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"ByteaArray\",\n        \"Int4Array\",\n        \"Int4Array\",\n        \"Int8Array\",\n        \"UuidArray\",\n        \"TimestamptzArray\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"06096ba1131e78d3da5df25a4b0a1193f11c9782abaf91faf263a116f90e51af\"\n}\n"
  },
  {
    "path": ".sqlx/query-075b89cfe2572d28e7adfc29bbe52fef4afdd5013686f7294efd966739886f0d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT email FROM af_user WHERE uid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"075b89cfe2572d28e7adfc29bbe52fef4afdd5013686f7294efd966739886f0d\"\n}\n"
  },
  {
    "path": ".sqlx/query-0781735c56d22370302beec06863dccbbb9e664b212de93e5073508a82b91609.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_workspace\\n      SET default_published_view_id = $1\\n      WHERE workspace_id = $2\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"0781735c56d22370302beec06863dccbbb9e664b212de93e5073508a82b91609\"\n}\n"
  },
  {
    "path": ".sqlx/query-081abcd7f80664e8acd205833b0f9ca43bc1ccc03d992e7b1c45c3e401a6007a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT\\n            database_storage_id\\n        FROM public.af_workspace\\n        WHERE workspace_id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"081abcd7f80664e8acd205833b0f9ca43bc1ccc03d992e7b1c45c3e401a6007a\"\n}\n"
  },
  {
    "path": ".sqlx/query-084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_workspace_invite_code (workspace_id, invite_code, expires_at)\\n      VALUES ($1, $2, $3)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Timestamp\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"084655c4e26f78c9c0924ea39a099dc9c00ee73dc6ade2dcff27c03042ebe8c3\"\n}\n"
  },
  {
    "path": ".sqlx/query-09cf032adce81ba99362b3df50ba104f4e1eb2d538350c65cf615ea13f1c37f0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_template_view\\n    WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"09cf032adce81ba99362b3df50ba104f4e1eb2d538350c65cf615ea13f1c37f0\"\n}\n"
  },
  {
    "path": ".sqlx/query-09ff850490eab213cfa0ad88ece9ce7baa39beabee19754fd993268d29552eb9.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO af_chat_messages (chat_id, author, content, meta_data)\\n        VALUES ($1, $2, $3, $4)\\n        RETURNING message_id, created_at\\n      \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"message_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Jsonb\",\n        \"Text\",\n        \"Jsonb\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"09ff850490eab213cfa0ad88ece9ce7baa39beabee19754fd993268d29552eb9\"\n}\n"
  },
  {
    "path": ".sqlx/query-0affbd65859d6299c6ba736797f970b86552b83d95316ec3f54f93501e00b522.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id\\n      FROM af_workspace\\n      WHERE owner_uid = (SELECT uid FROM public.af_user WHERE uuid = $1)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"0affbd65859d6299c6ba736797f970b86552b83d95316ec3f54f93501e00b522\"\n}\n"
  },
  {
    "path": ".sqlx/query-0d9c62acb33b96bb81536d1ad3121174403bcd40b777eb8d384fe8e81e1db3c4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT invite_code\\n      FROM af_workspace_invite_code\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"invite_code\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"0d9c62acb33b96bb81536d1ad3121174403bcd40b777eb8d384fe8e81e1db3c4\"\n}\n"
  },
  {
    "path": ".sqlx/query-0eeb2af3c6974c7e6d1c20bb4b08965eae9b0a291c7cef6451208b7740b9804c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH last_mentioned AS (\\n        SELECT\\n          person_id,\\n          MAX(mentioned_at) AS last_mentioned_at\\n        FROM af_page_mention\\n        WHERE workspace_id = $1\\n        GROUP BY person_id\\n      )\\n\\n      SELECT\\n        au.uuid,\\n        COALESCE(awmp.name, au.name) AS \\\"name!\\\",\\n        au.email,\\n        awm.role_id AS \\\"role!\\\",\\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \\\"avatar_url\\\",\\n        awmp.cover_image_url,\\n        awmp.custom_image_url,\\n        awmp.description,\\n        lm.last_mentioned_at\\n      FROM af_workspace_member awm\\n      JOIN af_user au ON awm.uid = au.uid\\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\\n      LEFT JOIN last_mentioned lm ON au.uuid = lm.person_id\\n      WHERE awm.workspace_id = $1\\n      ORDER BY lm.last_mentioned_at DESC NULLS LAST\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"cover_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"custom_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"last_mentioned_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null,\n      false,\n      false,\n      null,\n      true,\n      true,\n      true,\n      null\n    ]\n  },\n  \"hash\": \"0eeb2af3c6974c7e6d1c20bb4b08965eae9b0a291c7cef6451208b7740b9804c\"\n}\n"
  },
  {
    "path": ".sqlx/query-12c52797d87c0ec56ffe6d8baf24501a276fdac4453399190dc221de89b611f8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, namespace, is_original\\n      FROM af_workspace_namespace\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"namespace\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"is_original\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"12c52797d87c0ec56ffe6d8baf24501a276fdac4453399190dc221de89b611f8\"\n}\n"
  },
  {
    "path": ".sqlx/query-1545a42d784a1a5fa8e9ed6128814608b9230b64ce23dcd85de444a7aa01bf9e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        au.uuid,\\n        COALESCE(awmp.name, au.name) AS \\\"name!\\\",\\n        au.email,\\n        awm.role_id AS \\\"role!\\\",\\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \\\"avatar_url\\\",\\n        awmp.cover_image_url,\\n        awmp.custom_image_url,\\n        awmp.description\\n      FROM af_workspace_member awm\\n      JOIN af_user au ON awm.uid = au.uid\\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\\n      WHERE awm.workspace_id = $1\\n      AND au.uuid = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"cover_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"custom_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null,\n      false,\n      false,\n      null,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"1545a42d784a1a5fa8e9ed6128814608b9230b64ce23dcd85de444a7aa01bf9e\"\n}\n"
  },
  {
    "path": ".sqlx/query-15613595695e2e722c45712931ce0eb8d2a3deb1bb665d1f091f354a3ad96b92.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT EXISTS(\\n        SELECT 1\\n        FROM af_published_collab\\n        WHERE workspace_id = $1\\n          AND publish_name = $2\\n          AND unpublished_at IS NULL\\n      )\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"15613595695e2e722c45712931ce0eb8d2a3deb1bb665d1f091f354a3ad96b92\"\n}\n"
  },
  {
    "path": ".sqlx/query-16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_published_view_reaction (comment_id, view_id, created_by, reaction_type)\\n      VALUES ($1, $2, (SELECT uid FROM af_user WHERE uuid = $3), $4)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"16208887bc2f2ca6b5f3df8062a12b482908f9f113c0474eeae75f6784b5e0fc\"\n}\n"
  },
  {
    "path": ".sqlx/query-18207c125d5f974894576ee1dcfe406b221e9119f570403ec7a41ae1359b3f6c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n        workspace_id,\\n        inviter AS inviter_uid,\\n        (SELECT uid FROM public.af_user WHERE LOWER(email) = LOWER(invitee_email)) AS invitee_uid,\\n        status,\\n        role_id AS role\\n    FROM\\n    public.af_workspace_invitation\\n    WHERE id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"inviter_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"invitee_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"status\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      null,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"18207c125d5f974894576ee1dcfe406b221e9119f570403ec7a41ae1359b3f6c\"\n}\n"
  },
  {
    "path": ".sqlx/query-1ae2809504bb6ea7dabcb5b5acfed09b0dd2e382e9fec3430680192df63876b8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT af.uuid\\n      FROM af_published_collab apc\\n      JOIN af_user af ON af.uid = apc.published_by\\n      WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"1ae2809504bb6ea7dabcb5b5acfed09b0dd2e382e9fec3430680192df63876b8\"\n}\n"
  },
  {
    "path": ".sqlx/query-1b1ff4352abb6dad982279ee99c8dccb3621b55a838998c1b9803982ae10f622.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \" SELECT uid, uuid FROM af_user\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": []\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"1b1ff4352abb6dad982279ee99c8dccb3621b55a838998c1b9803982ae10f622\"\n}\n"
  },
  {
    "path": ".sqlx/query-1bd79541a2b351b11ae94fe8a7aad408f9b563fd123099aa701a1e07ce797d2f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      af_user.uuid\\n    FROM public.af_workspace_member\\n        JOIN public.af_user ON af_workspace_member.uid = af_user.uid\\n    WHERE af_workspace_member.workspace_id = $1\\n    AND role_id != $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"1bd79541a2b351b11ae94fe8a7aad408f9b563fd123099aa701a1e07ce797d2f\"\n}\n"
  },
  {
    "path": ".sqlx/query-1c8f022ff5add11376dbbc17efd874dd31fd908c4f17be1bded18dbc689e3b36.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    UPDATE public.af_workspace\\n    SET is_initialized = $2\\n    WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"1c8f022ff5add11376dbbc17efd874dd31fd908c4f17be1bded18dbc689e3b36\"\n}\n"
  },
  {
    "path": ".sqlx/query-1e36d9b3adf957524af88f997f12e5eeeaabda218c3709540e4a4c2df0180047.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_workspace\\n      SET settings = $1\\n      WHERE workspace_id = $2\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Jsonb\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"1e36d9b3adf957524af88f997f12e5eeeaabda218c3709540e4a4c2df0180047\"\n}\n"
  },
  {
    "path": ".sqlx/query-21195760ea7ed2dc4eda1dc2bd0eed9afcc63651ba6e67e7db675307e3b87821.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE public.af_workspace\\n      SET workspace_name = $1\\n      WHERE workspace_id = $2\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"21195760ea7ed2dc4eda1dc2bd0eed9afcc63651ba6e67e7db675307e3b87821\"\n}\n"
  },
  {
    "path": ".sqlx/query-2167ca10f5c560d8d4121d57d425c84482fa1dd52ee6f2cc7934e7d356b0dee6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT default_published_view_id\\n      FROM af_workspace\\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"default_published_view_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"2167ca10f5c560d8d4121d57d425c84482fa1dd52ee6f2cc7934e7d356b0dee6\"\n}\n"
  },
  {
    "path": ".sqlx/query-21f66ca39be3377f8c5e4b218123e266fe8e03260ecd1891c644820892dda2b2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT * FROM af_collab_snapshot\\n      WHERE sid = $1 AND oid = $2 AND workspace_id = $3 AND deleted_at IS NULL;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"sid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"oid\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"len\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"encrypt\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"21f66ca39be3377f8c5e4b218123e266fe8e03260ecd1891c644820892dda2b2\"\n}\n"
  },
  {
    "path": ".sqlx/query-223e530f8605f6d00789344565666f57705151e3c2318519e877b22f8ffc871b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        apc.view_id,\\n        apc.publish_name,\\n        au.email AS publisher_email,\\n        apc.created_at AS publish_timestamp,\\n        apc.comments_enabled,\\n        apc.duplicate_enabled\\n      FROM af_published_collab apc\\n      JOIN af_user au ON apc.published_by = au.uid\\n      WHERE workspace_id = $1\\n      AND unpublished_at IS NULL\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"publish_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"publisher_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"publish_timestamp\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"comments_enabled\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"duplicate_enabled\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"223e530f8605f6d00789344565666f57705151e3c2318519e877b22f8ffc871b\"\n}\n"
  },
  {
    "path": ".sqlx/query-229a99b7a3a2f136babd5499c2a1047fe840903acf0d06e57fb78ca9b03e7008.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT oid, snapshot, snapshot_version, created_at\\n        FROM af_snapshot_meta\\n        WHERE oid = $1 AND partition_key = $2\\n        ORDER BY created_at DESC\\n        LIMIT 1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"snapshot\",\n        \"type_info\": \"Bytea\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"snapshot_version\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"created_at\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"229a99b7a3a2f136babd5499c2a1047fe840903acf0d06e57fb78ca9b03e7008\"\n}\n"
  },
  {
    "path": ".sqlx/query-2394226650959b34ae80b1948b7a111720b3ea5da48934d8d7e395ecc84e6985.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    UPDATE af_template_view SET\\n      updated_at = NOW(),\\n      name = $2,\\n      description = $3,\\n      about = $4,\\n      view_url = $5,\\n      creator_id = $6,\\n      is_new_template = $7,\\n      is_featured = $8\\n      WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Uuid\",\n        \"Bool\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"2394226650959b34ae80b1948b7a111720b3ea5da48934d8d7e395ecc84e6985\"\n}\n"
  },
  {
    "path": ".sqlx/query-24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT name, email FROM af_user WHERE uuid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716\"\n}\n"
  },
  {
    "path": ".sqlx/query-2593b975fcf2dcf0129a1390fd8e2888d440e07c904d7eb3ca14957be8bc6069.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT * FROM public.af_permissions WHERE id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"access_level\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"2593b975fcf2dcf0129a1390fd8e2888d440e07c904d7eb3ca14957be8bc6069\"\n}\n"
  },
  {
    "path": ".sqlx/query-2902fd3a9faa9481754d38b29abb543640c0b5564dca8f0141c7de2b8aab9551.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT oid,workspace_id,owner_uid,deleted_at,created_at,updated_at\\n        FROM af_collab\\n        WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"2902fd3a9faa9481754d38b29abb543640c0b5564dca8f0141c7de2b8aab9551\"\n}\n"
  },
  {
    "path": ".sqlx/query-291f0916b7868f3598b50f659689b9c77d34112c2a2fff9fc04775da9f97e46d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT EXISTS(\\n      SELECT 1\\n      FROM af_workspace\\n      WHERE workspace_id = $1\\n    ) AS user_exists;\\n  \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"user_exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"291f0916b7868f3598b50f659689b9c77d34112c2a2fff9fc04775da9f97e46d\"\n}\n"
  },
  {
    "path": ".sqlx/query-29279a0a97beb08aea84d588374c7534c28bd9c4da24b1ee20245109f5c33880.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE af_workspace_member\\n        SET updated_at = $3\\n        WHERE uid = $1\\n        AND workspace_id = $2;\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"29279a0a97beb08aea84d588374c7534c28bd9c4da24b1ee20245109f5c33880\"\n}\n"
  },
  {
    "path": ".sqlx/query-2b0754f55889a20c294d2a77ba8d3fa34c8174856abfdede34797851183a177a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n  SELECT EXISTS (\\n    SELECT 1\\n    FROM public.af_workspace\\n    WHERE\\n        workspace_id = $1\\n        AND owner_uid = (\\n            SELECT uid FROM public.af_user WHERE email = $2\\n        )\\n   ) AS \\\"is_owner\\\";\\n  \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"is_owner\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"2b0754f55889a20c294d2a77ba8d3fa34c8174856abfdede34797851183a177a\"\n}\n"
  },
  {
    "path": ".sqlx/query-2c0a776a787bc748857873b682d2fa3c549ffeaf767aa8ee05b09b3857505ded.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\nSELECT\\n  w.settings['disable_search_indexing']::boolean as disable_search_indexing,\\n  CASE\\n    WHEN w.settings['disable_search_indexing']::boolean THEN\\n      FALSE\\n    ELSE\\n      EXISTS (SELECT 1 FROM af_collab_embeddings m WHERE m.oid = $2::uuid)\\n  END as has_index\\nFROM af_workspace w\\nWHERE w.workspace_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"disable_search_indexing\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"has_index\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null,\n      null\n    ]\n  },\n  \"hash\": \"2c0a776a787bc748857873b682d2fa3c549ffeaf767aa8ee05b09b3857505ded\"\n}\n"
  },
  {
    "path": ".sqlx/query-2c496e29533dd27117fbb688ba2324f04d7cc306181fcf3f82079d5639f632c4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE af_chat\\n        SET deleted_at = now()\\n        WHERE chat_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"2c496e29533dd27117fbb688ba2324f04d7cc306181fcf3f82079d5639f632c4\"\n}\n"
  },
  {
    "path": ".sqlx/query-2d6d00669ea7d598d69d848d143f33e8c144d35b3d4c5293f98344b2c62fe6c8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT namespace\\n      FROM af_workspace_namespace\\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\\n        AND is_original = FALSE\\n      ORDER BY created_at DESC\\n      LIMIT 1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"namespace\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"2d6d00669ea7d598d69d848d143f33e8c144d35b3d4c5293f98344b2c62fe6c8\"\n}\n"
  },
  {
    "path": ".sqlx/query-2dda0bc4d9486a49c0af00d8ee4408c970a2ba3533217c130281e7db5a4e3d6b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, metadata\\n      FROM af_published_collab\\n      WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"metadata\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"2dda0bc4d9486a49c0af00d8ee4408c970a2ba3533217c130281e7db5a4e3d6b\"\n}\n"
  },
  {
    "path": ".sqlx/query-30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT EXISTS(\\n      SELECT true\\n      FROM af_published_collab\\n      WHERE view_id = $1\\n        AND published_by = (SELECT uid FROM af_user WHERE uuid = $2)\\n      UNION ALL\\n      SELECT true\\n      FROM af_published_view_comment\\n      WHERE view_id = $1\\n        AND comment_id = $3\\n        AND created_by = (SELECT uid FROM af_user WHERE uuid = $2)\\n    ) AS \\\"exists\\\";\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"30a592588fe20bb1444178b7ee9e73e37d1d55572f936988528178bfa10158e5\"\n}\n"
  },
  {
    "path": ".sqlx/query-315840e0657ea0b8d162635b4cc21ce84a09fd7ea14ea07980869a80ee06900c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_page_mention (workspace_id, view_id, view_name, person_id, block_id, mentioned_by, mentioned_at, require_notification)\\n      VALUES ($1, $2, $3, $4, $5, $6, current_timestamp, $7)\\n      ON CONFLICT (workspace_id, view_id, person_id) DO UPDATE\\n      SET mentioned_by = EXCLUDED.mentioned_by,\\n          mentioned_at = EXCLUDED.mentioned_at,\\n          block_id = EXCLUDED.block_id,\\n          require_notification = EXCLUDED.require_notification,\\n          notified = false\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Uuid\",\n        \"Text\",\n        \"Int8\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"315840e0657ea0b8d162635b4cc21ce84a09fd7ea14ea07980869a80ee06900c\"\n}\n"
  },
  {
    "path": ".sqlx/query-32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_workspace_member (workspace_id, uid, role_id)\\n      VALUES ($1, $2, $3)\\n      ON CONFLICT (workspace_id, uid) DO NOTHING\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"32fd3dcd1a3e02c32ddedb232b6af2e7f9ea160354528f3299cca62367af10f7\"\n}\n"
  },
  {
    "path": ".sqlx/query-340b8cef5a7676541b86505cdf103fcb5b54c40a9d6e599dc1d9dc0a95e1e862.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH creator_number_of_templates AS (\\n      SELECT\\n        creator_id,\\n        COUNT(1)::int AS number_of_templates\\n      FROM af_template_view\\n      WHERE name ILIKE $1\\n      GROUP BY creator_id\\n    )\\n\\n    SELECT\\n      creator.creator_id AS \\\"id!\\\",\\n      name AS \\\"name!\\\",\\n      avatar_url AS \\\"avatar_url!\\\",\\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \\\"account_links: Vec<AccountLinkColumn>\\\",\\n      COALESCE(number_of_templates, 0) AS \\\"number_of_templates!\\\"\\n    FROM af_template_creator creator\\n    LEFT OUTER JOIN af_template_creator_account_link account_link\\n    USING (creator_id)\\n    LEFT OUTER JOIN creator_number_of_templates\\n    USING (creator_id)\\n    WHERE name ILIKE $1\\n    GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\\n    ORDER BY created_at ASC\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"avatar_url!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"account_links: Vec<AccountLinkColumn>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"number_of_templates!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"340b8cef5a7676541b86505cdf103fcb5b54c40a9d6e599dc1d9dc0a95e1e862\"\n}\n"
  },
  {
    "path": ".sqlx/query-354166a6fa147dc6e17bfc14cb68d3a72a2e7c3aa2d115686deb12086786e034.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n     SELECT role_id FROM af_workspace_member\\n     WHERE workspace_id = $1 AND uid = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"role_id\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"354166a6fa147dc6e17bfc14cb68d3a72a2e7c3aa2d115686deb12086786e034\"\n}\n"
  },
  {
    "path": ".sqlx/query-35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_quick_note (workspace_id, uid, data) VALUES ($1, $2, $3)\\n      RETURNING quick_note_id AS id, data, created_at AS \\\"created_at!\\\", updated_at AS \\\"last_updated_at!\\\"\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"data\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"last_updated_at!\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Jsonb\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"35622d4ebede28dd28b613edcf3970ad258286f176ce86e88bd662a602e4ad58\"\n}\n"
  },
  {
    "path": ".sqlx/query-36733444fc8fac851fb540105ea6c9dca785455ae44ae518b98d8b57082e11d8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n  SELECT EXISTS(\\n    SELECT 1\\n    FROM public.af_workspace_member\\n      JOIN af_roles ON af_workspace_member.role_id = af_roles.id\\n    WHERE workspace_id = $1\\n    AND af_workspace_member.uid = (\\n      SELECT uid FROM public.af_user WHERE uuid = $2\\n    )\\n    AND af_roles.name = 'Owner'\\n  ) AS \\\"exists\\\";\\n  \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"36733444fc8fac851fb540105ea6c9dca785455ae44ae518b98d8b57082e11d8\"\n}\n"
  },
  {
    "path": ".sqlx/query-3865d921d76ac0d0eb16065738cddf82cb71945504116b0a04da759209b9c250.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT oid, indexed_at\\n        FROM af_collab\\n        WHERE oid = ANY (SELECT UNNEST($1::uuid[]))\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"indexed_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      true\n    ]\n  },\n  \"hash\": \"3865d921d76ac0d0eb16065738cddf82cb71945504116b0a04da759209b9c250\"\n}\n"
  },
  {
    "path": ".sqlx/query-3b2daf263b4022e69c819edb55d412da8ad3fe4377155d8485fbaf186069f389.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        uuid,\\n        email,\\n        name,\\n        metadata ->> 'icon_url' AS avatar_url\\n      FROM af_user\\n      WHERE uid = $1;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"3b2daf263b4022e69c819edb55d412da8ad3fe4377155d8485fbaf186069f389\"\n}\n"
  },
  {
    "path": ".sqlx/query-3bb5b82d46c55bbfd51319310a3cd065c4b796462a1ddf3c17617ee65ce9961a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n       INSERT INTO af_chat (chat_id, name, workspace_id, rag_ids)\\n       VALUES ($1, $2, $3, $4)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Uuid\",\n        \"Jsonb\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"3bb5b82d46c55bbfd51319310a3cd065c4b796462a1ddf3c17617ee65ce9961a\"\n}\n"
  },
  {
    "path": ".sqlx/query-3c2c94b9ac0a329b92847d7176a7435f894c5ef3b3b11e3e2ae03a8ec454a6d8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH af_user_row AS (\\n        SELECT * FROM af_user WHERE uuid = $1\\n      )\\n      SELECT\\n        af_user_row.uid,\\n        af_user_row.uuid,\\n        af_user_row.email,\\n        af_user_row.password,\\n        af_user_row.name,\\n        af_user_row.metadata,\\n        af_user_row.encryption_sign,\\n        af_user_row.deleted_at,\\n        af_user_row.updated_at,\\n        af_user_row.created_at,\\n       (\\n         SELECT af_workspace_member.workspace_id\\n         FROM af_workspace_member\\n         JOIN af_workspace\\n           ON af_workspace_member.workspace_id = af_workspace.workspace_id\\n         WHERE af_workspace_member.uid = af_user_row.uid\\n           AND COALESCE(af_workspace.is_initialized, true) = true\\n         ORDER BY af_workspace_member.updated_at DESC\\n         LIMIT 1\\n       ) AS latest_workspace_id\\n      FROM af_user_row\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"password\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"metadata\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"encryption_sign\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"latest_workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true,\n      null\n    ]\n  },\n  \"hash\": \"3c2c94b9ac0a329b92847d7176a7435f894c5ef3b3b11e3e2ae03a8ec454a6d8\"\n}\n"
  },
  {
    "path": ".sqlx/query-3ca587826f0598e7786c765dcb2fcd6ae08d8aa404f02920307547c769a3f91b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH\\n      updated_creator AS (\\n        UPDATE af_template_creator\\n          SET name = $2, avatar_url = $3, updated_at = NOW()\\n          WHERE creator_id = $1\\n        RETURNING creator_id, name, avatar_url\\n      ),\\n      account_links AS (\\n        INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\\n        SELECT updated_creator.creator_id as creator_id, link_type, url FROM\\n        UNNEST($4::text[], $5::text[]) AS t(link_type, url)\\n        CROSS JOIN updated_creator\\n        RETURNING\\n          creator_id,\\n          link_type,\\n          url\\n      ),\\n      creator_number_of_templates AS (\\n        SELECT\\n          creator_id,\\n          COUNT(1)::int AS number_of_templates\\n        FROM af_template_view\\n        WHERE creator_id = $1\\n        GROUP BY creator_id\\n      )\\n    SELECT\\n      updated_creator.creator_id AS id,\\n      name,\\n      avatar_url,\\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \\\"account_links: Vec<AccountLinkColumn>\\\",\\n      COALESCE(number_of_templates, 0) AS \\\"number_of_templates!\\\"\\n      FROM updated_creator\\n      LEFT OUTER JOIN account_links\\n      USING (creator_id)\\n      LEFT OUTER JOIN creator_number_of_templates\\n      USING (creator_id)\\n      GROUP BY (id, name, avatar_url, number_of_templates)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"account_links: Vec<AccountLinkColumn>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"number_of_templates!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"TextArray\",\n        \"TextArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"3ca587826f0598e7786c765dcb2fcd6ae08d8aa404f02920307547c769a3f91b\"\n}\n"
  },
  {
    "path": ".sqlx/query-3cfb0a6d9a798f29422bc4bf4a52d3c86c3aae98c173b83c60eb57504a3d2c7c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_snapshot_meta (oid, workspace_id, snapshot, snapshot_version, partition_key, created_at)\\n    VALUES ($1, $2, $3, $4, $5, $6)\\n    ON CONFLICT DO NOTHING\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\",\n        \"Bytea\",\n        \"Int4\",\n        \"Int4\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"3cfb0a6d9a798f29422bc4bf4a52d3c86c3aae98c173b83c60eb57504a3d2c7c\"\n}\n"
  },
  {
    "path": ".sqlx/query-3d3309a4ae7a88b3f7c9608dd78a1c1dc9b237a37e29722bcd2910bd23f9d873.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_related_template_view\\n    WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"3d3309a4ae7a88b3f7c9608dd78a1c1dc9b237a37e29722bcd2910bd23f9d873\"\n}\n"
  },
  {
    "path": ".sqlx/query-3fdd28c263edf5c91ab8b770e6106d4890ec4bae2ff3c20f80c40cb4042d9e03.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT SUM(len) FROM af_collab WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"sum\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"3fdd28c263edf5c91ab8b770e6106d4890ec4bae2ff3c20f80c40cb4042d9e03\"\n}\n"
  },
  {
    "path": ".sqlx/query-40db0a61665bdb9f7e9d1ce2a6c0eb05703e36e83c87802a72630388588de8cd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, COUNT(*) AS member_count\\n      FROM af_workspace_member\\n      WHERE workspace_id = ANY($1) AND role_id != $2\\n      GROUP BY workspace_id\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"member_count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null\n    ]\n  },\n  \"hash\": \"40db0a61665bdb9f7e9d1ce2a6c0eb05703e36e83c87802a72630388588de8cd\"\n}\n"
  },
  {
    "path": ".sqlx/query-4123fa8796e8b56225155f79c2ee4c4dacda5ef51e858ce7dcb9877c7d55bd53.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH invited_workspace_member AS (\\n        SELECT\\n          invite_code,\\n          COUNT(*) AS member_count,\\n          COUNT(CASE WHEN uid = $2 THEN uid END) > 0 AS is_member\\n        FROM af_workspace_invite_code\\n        JOIN af_workspace_member USING (workspace_id)\\n        WHERE invite_code = $1\\n        AND (expires_at IS NULL OR expires_at > NOW())\\n        GROUP BY invite_code\\n      )\\n      SELECT\\n      workspace_id,\\n      owner_profile.name AS \\\"owner_name!\\\",\\n      owner_profile.metadata ->> 'icon_url' AS owner_avatar,\\n      af_workspace.workspace_name AS \\\"workspace_name!\\\",\\n      af_workspace.icon AS workspace_icon_url,\\n      invited_workspace_member.member_count AS \\\"member_count!\\\",\\n      invited_workspace_member.is_member AS \\\"is_member!\\\"\\n      FROM af_workspace_invite_code\\n      JOIN af_workspace USING (workspace_id)\\n      JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\\n      JOIN invited_workspace_member USING (invite_code)\\n      WHERE invite_code = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"owner_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_avatar\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"workspace_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"workspace_icon_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"member_count!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"is_member!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      null,\n      true,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"4123fa8796e8b56225155f79c2ee4c4dacda5ef51e858ce7dcb9877c7d55bd53\"\n}\n"
  },
  {
    "path": ".sqlx/query-425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_template_category\\n    WHERE category_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"425b0b5ffbe3f1b80aedf15b8df1640c879d8d45883eee8b1e2fbd64eaf283d6\"\n}\n"
  },
  {
    "path": ".sqlx/query-441316f35ca8c24bf78167f9fec48e28c05969bbbbe3d0e3d9e1569a375de476.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT * FROM af_blob_metadata\\n        WHERE workspace_id = $1 AND file_id = $2\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"file_id\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"file_type\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"file_size\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"modified_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"source\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"source_metadata\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"441316f35ca8c24bf78167f9fec48e28c05969bbbbe3d0e3d9e1569a375de476\"\n}\n"
  },
  {
    "path": ".sqlx/query-4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      DELETE FROM af_access_request\\n      WHERE request_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"4476f271f4ea8c83428b4178c43ee2894e380a7c3ae3cbc782f438fabc45de8b\"\n}\n"
  },
  {
    "path": ".sqlx/query-44e4be501db0375fbd8ad8ed923bef887e361fe466ab46bdd6663f6cf97413a8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH new_workspace AS (\\n      INSERT INTO public.af_workspace (owner_uid, workspace_name, icon, is_initialized)\\n      VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2, $3, $4)\\n      RETURNING *\\n    )\\n    SELECT\\n      workspace_id,\\n      database_storage_id,\\n      owner_uid,\\n      owner_profile.name AS owner_name,\\n      owner_profile.email AS owner_email,\\n      new_workspace.created_at,\\n      workspace_type,\\n      new_workspace.deleted_at,\\n      workspace_name,\\n      icon\\n    FROM new_workspace\\n    JOIN public.af_user AS owner_profile ON new_workspace.owner_uid = owner_profile.uid;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"owner_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"owner_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"44e4be501db0375fbd8ad8ed923bef887e361fe466ab46bdd6663f6cf97413a8\"\n}\n"
  },
  {
    "path": ".sqlx/query-4f5951e61713d04963524b84648c9ff8c7be05f0089f6fd26fc6e0e0afeae579.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT reply_message_id\\n      FROM af_chat_messages\\n      WHERE message_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"reply_message_id\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"4f5951e61713d04963524b84648c9ff8c7be05f0089f6fd26fc6e0e0afeae579\"\n}\n"
  },
  {
    "path": ".sqlx/query-4fc0611c846f86be652d42eb8ae21a5da0353fe810856aaabe91d7963329d098.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n          ac.oid as object_id,\\n          ace.partition_key,\\n          ac.indexed_at,\\n          ace.updated_at\\n      FROM af_collab_embeddings ac\\n      JOIN af_collab ace ON ac.oid = ace.oid\\n      WHERE ac.oid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"object_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"partition_key\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"indexed_at\",\n        \"type_info\": \"Timestamp\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"4fc0611c846f86be652d42eb8ae21a5da0353fe810856aaabe91d7963329d098\"\n}\n"
  },
  {
    "path": ".sqlx/query-51a3a723b1825da7b9abd9cb36db0cf8220abf063098a73e4a6fc3f87352b395.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT metadata\\n    FROM af_published_collab\\n    WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\\n      AND unpublished_at IS NULL\\n      AND publish_name = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"metadata\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"51a3a723b1825da7b9abd9cb36db0cf8220abf063098a73e4a6fc3f87352b395\"\n}\n"
  },
  {
    "path": ".sqlx/query-523087b0101a35abfc70a561272acec7a357491a86901f7927b8242173b5c8c8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH\\n      new_creator AS (\\n        INSERT INTO af_template_creator (name, avatar_url)\\n        VALUES ($1, $2)\\n        RETURNING creator_id, name, avatar_url\\n      ),\\n      account_links AS (\\n        INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\\n        SELECT new_creator.creator_id as creator_id, link_type, url FROM\\n        UNNEST($3::text[], $4::text[]) AS t(link_type, url)\\n        CROSS JOIN new_creator\\n        RETURNING\\n          creator_id,\\n          link_type,\\n          url\\n      )\\n    SELECT\\n      new_creator.creator_id AS id,\\n      name,\\n      avatar_url,\\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \\\"account_links: Vec<AccountLinkColumn>\\\",\\n      0 AS \\\"number_of_templates!\\\"\\n      FROM new_creator\\n      LEFT OUTER JOIN account_links\\n      USING (creator_id)\\n      GROUP BY (id, name, avatar_url)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"account_links: Vec<AccountLinkColumn>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"number_of_templates!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"TextArray\",\n        \"TextArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"523087b0101a35abfc70a561272acec7a357491a86901f7927b8242173b5c8c8\"\n}\n"
  },
  {
    "path": ".sqlx/query-52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO public.af_workspace_invitation (\\n          id,\\n          workspace_id,\\n          inviter,\\n          invitee_email,\\n          role_id\\n      )\\n      VALUES (\\n        $1,\\n        $2,\\n        (SELECT uid FROM public.af_user WHERE uuid = $3),\\n        $4,\\n        $5\\n      )\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"52b936c6adf43ec5c7e777ad9379dec30b750fefad73684e552481f709006d04\"\n}\n"
  },
  {
    "path": ".sqlx/query-53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        avr.comment_id,\\n        avr.reaction_type,\\n        ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \\\"react_users!: Vec<AFWebUserWithEmailColumn>\\\"\\n      FROM af_published_view_reaction avr\\n      INNER JOIN af_user au ON avr.created_by = au.uid\\n      WHERE view_id = $1\\n      GROUP BY comment_id, reaction_type\\n      ORDER BY MIN(avr.created_at)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"reaction_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"react_users!: Vec<AFWebUserWithEmailColumn>\",\n        \"type_info\": \"RecordArray\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb\"\n}\n"
  },
  {
    "path": ".sqlx/query-594af4041e0778476a699536316007f0a264f7d3db9de6326ef8082a2a898995.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT id, invitee_email\\n      FROM public.af_workspace_invitation\\n      WHERE workspace_id = $1\\n      AND status = 0\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"invitee_email\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"594af4041e0778476a699536316007f0a264f7d3db9de6326ef8082a2a898995\"\n}\n"
  },
  {
    "path": ".sqlx/query-598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_access_request (\\n        workspace_id,\\n        view_id,\\n        uid,\\n        status\\n      )\\n      VALUES ($1, $2, $3, $4)\\n      RETURNING request_id\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"request_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Int8\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"598e731078fc6417039cc16772eb5bc6c74d24c1a8018a981d2175a483dc699c\"\n}\n"
  },
  {
    "path": ".sqlx/query-59b2a7854bb8f0d7ee34b9dfa4e3db5cac8e25fdebe186ba2cbd65012eb91f5f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, view_id\\n      FROM af_published_collab\\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\\n      AND publish_name = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"59b2a7854bb8f0d7ee34b9dfa4e3db5cac8e25fdebe186ba2cbd65012eb91f5f\"\n}\n"
  },
  {
    "path": ".sqlx/query-5c2d58bfdedbb1be71337a97d5ed5a2921f83dd549507b2834a4d2582d2c361b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        DELETE FROM af_collab_embeddings e\\n        USING af_collab c\\n        WHERE e.oid = c.oid\\n          AND c.workspace_id = $1\\n      \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5c2d58bfdedbb1be71337a97d5ed5a2921f83dd549507b2834a4d2582d2c361b\"\n}\n"
  },
  {
    "path": ".sqlx/query-5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"DELETE FROM af_quick_note WHERE quick_note_id = $1\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5cce5f82c0fb9237f724478e2167243bc772c092910f07b8226431a6dd70a7da\"\n}\n"
  },
  {
    "path": ".sqlx/query-5d408d36790ade4da1ceeb68b4a183aa7d9abc27b0ec42c2a3c5af26ad80f128.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT uid FROM af_user WHERE uuid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"5d408d36790ade4da1ceeb68b4a183aa7d9abc27b0ec42c2a3c5af26ad80f128\"\n}\n"
  },
  {
    "path": ".sqlx/query-5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_template_creator_account_link\\n    WHERE creator_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"5d51aef40f7e0716338b406263240dbc5e4a64cec6f1be10a3676e4f86ce4557\"\n}\n"
  },
  {
    "path": ".sqlx/query-5e0d58f612425e1cf36dfc7f56691cfb8f6def1a3d29645922cb437d11ce62ef.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT COUNT(*)\\n        FROM public.af_chat_messages\\n        WHERE chat_id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"5e0d58f612425e1cf36dfc7f56691cfb8f6def1a3d29645922cb437d11ce62ef\"\n}\n"
  },
  {
    "path": ".sqlx/query-620167841bb2acdd1c9c6aadf8245e3a483d87dc006d4e361e994ce2c5d768cd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT EXISTS(\\n        SELECT 1\\n        FROM af_workspace_namespace\\n        WHERE namespace = $1\\n      )\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"620167841bb2acdd1c9c6aadf8245e3a483d87dc006d4e361e994ce2c5d768cd\"\n}\n"
  },
  {
    "path": ".sqlx/query-62ed61bcf92fc0c3756f57d0fe05cdd12e70072f5646fe48790ad189a6e96b12.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH template_with_creator_account_link AS (\\n        SELECT\\n          template.view_id,\\n          template.creator_id,\\n          COALESCE(\\n            ARRAY_AGG((link_type, url)::account_link_type) FILTER (WHERE link_type IS NOT NULL),\\n            '{}'\\n          ) AS account_links\\n        FROM af_template_view template\\n        JOIN af_published_collab\\n        USING (view_id)\\n        JOIN af_template_creator creator\\n        USING (creator_id)\\n        LEFT OUTER JOIN af_template_creator_account_link account_link\\n        USING (creator_id)\\n        WHERE view_id = $1\\n        GROUP BY (view_id, template.creator_id)\\n      ),\\n      related_template_with_category AS (\\n        SELECT\\n          template.related_view_id,\\n          ARRAY_AGG(\\n            (\\n              template_category.category_id,\\n              template_category.name,\\n              template_category.icon,\\n              template_category.bg_color\\n            )::template_category_minimal_type\\n          ) AS categories\\n        FROM af_related_template_view template\\n        JOIN af_template_view_template_category template_template_category\\n        ON template.related_view_id = template_template_category.view_id\\n        JOIN af_template_category template_category\\n        USING (category_id)\\n        WHERE template.view_id = $1\\n        GROUP BY template.related_view_id\\n      ),\\n      template_with_related_template AS (\\n        SELECT\\n          template.view_id,\\n          ARRAY_AGG(\\n            (\\n              template.related_view_id,\\n              related_template.created_at,\\n              related_template.updated_at,\\n              related_template.name,\\n              related_template.description,\\n              related_template.view_url,\\n              (\\n                creator.creator_id,\\n                creator.name,\\n                creator.avatar_url\\n              )::template_creator_minimal_type,\\n              related_template_with_category.categories,\\n              related_template.is_new_template,\\n              related_template.is_featured\\n            )::template_minimal_type\\n          ) AS related_templates\\n        FROM af_related_template_view template\\n        JOIN af_template_view related_template\\n        ON template.related_view_id = related_template.view_id\\n        JOIN af_template_creator creator\\n        ON related_template.creator_id = creator.creator_id\\n        JOIN related_template_with_category\\n        ON template.related_view_id = related_template_with_category.related_view_id\\n        WHERE template.view_id = $1\\n        GROUP BY template.view_id\\n      ),\\n      template_with_category AS (\\n        SELECT\\n          view_id,\\n          COALESCE(\\n            ARRAY_AGG((\\n              vtc.category_id,\\n              name,\\n              icon,\\n              bg_color,\\n              description,\\n              category_type,\\n              priority\\n            )) FILTER (WHERE vtc.category_id IS NOT NULL),\\n            '{}'\\n          ) AS categories\\n        FROM af_template_view_template_category vtc\\n        JOIN af_template_category tc\\n        ON vtc.category_id = tc.category_id\\n        WHERE view_id = $1\\n        GROUP BY view_id\\n      ),\\n      creator_number_of_templates AS (\\n        SELECT\\n          creator_id,\\n          COUNT(*) AS number_of_templates\\n        FROM af_template_view\\n        GROUP BY creator_id\\n      )\\n\\n      SELECT\\n        template.view_id,\\n        template.created_at,\\n        template.updated_at,\\n        template.name,\\n        template.description,\\n        template.about,\\n        template.view_url,\\n        (\\n          creator.creator_id,\\n          creator.name,\\n          creator.avatar_url,\\n          template_with_creator_account_link.account_links,\\n          creator_number_of_templates.number_of_templates\\n        )::template_creator_type AS \\\"creator!: AFTemplateCreatorRow\\\",\\n        template_with_category.categories AS \\\"categories!: Vec<AFTemplateCategoryRow>\\\",\\n        COALESCE(template_with_related_template.related_templates, '{}') AS \\\"related_templates!: Vec<AFTemplateMinimalRow>\\\",\\n        template.is_new_template,\\n        template.is_featured\\n      FROM af_template_view template\\n      JOIN af_template_creator creator\\n      USING (creator_id)\\n      JOIN template_with_creator_account_link\\n      ON template.view_id = template_with_creator_account_link.view_id\\n      LEFT OUTER JOIN template_with_related_template\\n      ON template.view_id = template_with_related_template.view_id\\n      JOIN template_with_category\\n      ON template.view_id = template_with_category.view_id\\n      LEFT OUTER JOIN creator_number_of_templates\\n      ON template.creator_id = creator_number_of_templates.creator_id\\n      WHERE template.view_id = $1\\n\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"about\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"view_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"creator!: AFTemplateCreatorRow\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"template_creator_type\",\n            \"kind\": {\n              \"Composite\": [\n                [\n                  \"creator_id\",\n                  \"Uuid\"\n                ],\n                [\n                  \"name\",\n                  \"Text\"\n                ],\n                [\n                  \"avatar_url\",\n                  \"Text\"\n                ],\n                [\n                  \"account_links\",\n                  {\n                    \"Custom\": {\n                      \"name\": \"account_link_type[]\",\n                      \"kind\": {\n                        \"Array\": {\n                          \"Custom\": {\n                            \"name\": \"account_link_type\",\n                            \"kind\": {\n                              \"Composite\": [\n                                [\n                                  \"link_type\",\n                                  \"Text\"\n                                ],\n                                [\n                                  \"url\",\n                                  \"Text\"\n                                ]\n                              ]\n                            }\n                          }\n                        }\n                      }\n                    }\n                  }\n                ],\n                [\n                  \"number_of_templates\",\n                  \"Int4\"\n                ]\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"categories!: Vec<AFTemplateCategoryRow>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"related_templates!: Vec<AFTemplateMinimalRow>\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"template_minimal_type[]\",\n            \"kind\": {\n              \"Array\": {\n                \"Custom\": {\n                  \"name\": \"template_minimal_type\",\n                  \"kind\": {\n                    \"Composite\": [\n                      [\n                        \"view_id\",\n                        \"Uuid\"\n                      ],\n                      [\n                        \"created_at\",\n                        \"Timestamptz\"\n                      ],\n                      [\n                        \"updated_at\",\n                        \"Timestamptz\"\n                      ],\n                      [\n                        \"name\",\n                        \"Text\"\n                      ],\n                      [\n                        \"description\",\n                        \"Text\"\n                      ],\n                      [\n                        \"view_url\",\n                        \"Text\"\n                      ],\n                      [\n                        \"creator\",\n                        {\n                          \"Custom\": {\n                            \"name\": \"template_creator_minimal_type\",\n                            \"kind\": {\n                              \"Composite\": [\n                                [\n                                  \"creator_id\",\n                                  \"Uuid\"\n                                ],\n                                [\n                                  \"name\",\n                                  \"Text\"\n                                ],\n                                [\n                                  \"avatar_url\",\n                                  \"Text\"\n                                ]\n                              ]\n                            }\n                          }\n                        }\n                      ],\n                      [\n                        \"categories\",\n                        {\n                          \"Custom\": {\n                            \"name\": \"template_category_minimal_type[]\",\n                            \"kind\": {\n                              \"Array\": {\n                                \"Custom\": {\n                                  \"name\": \"template_category_minimal_type\",\n                                  \"kind\": {\n                                    \"Composite\": [\n                                      [\n                                        \"category_id\",\n                                        \"Uuid\"\n                                      ],\n                                      [\n                                        \"name\",\n                                        \"Text\"\n                                      ],\n                                      [\n                                        \"icon\",\n                                        \"Text\"\n                                      ],\n                                      [\n                                        \"bg_color\",\n                                        \"Text\"\n                                      ]\n                                    ]\n                                  }\n                                }\n                              }\n                            }\n                          }\n                        }\n                      ],\n                      [\n                        \"is_new_template\",\n                        \"Bool\"\n                      ],\n                      [\n                        \"is_featured\",\n                        \"Bool\"\n                      ]\n                    ]\n                  }\n                }\n              }\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"is_new_template\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"is_featured\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      null,\n      null,\n      null,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"62ed61bcf92fc0c3756f57d0fe05cdd12e70072f5646fe48790ad189a6e96b12\"\n}\n"
  },
  {
    "path": ".sqlx/query-6380f5a6ded2dab8f18de42541c9d77c2f3af512e3f66e1b731ca7c00c9ea8f8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO public.af_workspace_member (workspace_id, uid, role_id)\\n      SELECT $1, af_user.uid, $3\\n      FROM public.af_user\\n      WHERE\\n        af_user.email = $2\\n      ON CONFLICT (workspace_id, uid)\\n      DO NOTHING;\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6380f5a6ded2dab8f18de42541c9d77c2f3af512e3f66e1b731ca7c00c9ea8f8\"\n}\n"
  },
  {
    "path": ".sqlx/query-63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        avr.reaction_type,\\n        ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \\\"react_users!: Vec<AFWebUserWithEmailColumn>\\\",\\n        avr.comment_id\\n      FROM af_published_view_reaction avr\\n      INNER JOIN af_user au ON avr.created_by = au.uid\\n      WHERE comment_id = $1\\n      GROUP BY comment_id, reaction_type\\n      ORDER BY MIN(avr.created_at)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"reaction_type\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"react_users!: Vec<AFWebUserWithEmailColumn>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null,\n      false\n    ]\n  },\n  \"hash\": \"63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f\"\n}\n"
  },
  {
    "path": ".sqlx/query-66218110851919b05b95b008a17547547d23f6baeeff8a5521b2b246126adc34.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT name, meta_data, rag_ids\\n        FROM af_chat\\n        WHERE chat_id = $1 AND deleted_at IS NULL\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"rag_ids\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"66218110851919b05b95b008a17547547d23f6baeeff8a5521b2b246126adc34\"\n}\n"
  },
  {
    "path": ".sqlx/query-6716ec4787f7155af97a4890730f4b3fe564ead8d99f8355ac249f9b39316238.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH workspace_member_count AS (\\n        SELECT\\n          workspace_id,\\n          COUNT(*) AS member_count\\n        FROM af_workspace_member\\n        WHERE workspace_id = $1 AND role_id != $3\\n        GROUP BY workspace_id\\n      )\\n\\n      SELECT\\n        af_workspace.workspace_id,\\n        database_storage_id,\\n        owner_uid,\\n        owner_profile.name as owner_name,\\n        owner_profile.email as owner_email,\\n        af_workspace.created_at,\\n        workspace_type,\\n        af_workspace.deleted_at,\\n        workspace_name,\\n        icon,\\n        workspace_member_count.member_count AS \\\"member_count!\\\",\\n        role_id AS \\\"role!\\\"\\n      FROM public.af_workspace\\n      JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\\n      JOIN af_workspace_member ON (af_workspace.workspace_id = af_workspace_member.workspace_id\\n        AND af_workspace_member.uid = $2)\\n      JOIN workspace_member_count ON af_workspace.workspace_id = workspace_member_count.workspace_id\\n      WHERE af_workspace.workspace_id = $1\\n        AND COALESCE(af_workspace.is_initialized, true) = true;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"owner_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"owner_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"member_count!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      true,\n      false,\n      null,\n      false\n    ]\n  },\n  \"hash\": \"6716ec4787f7155af97a4890730f4b3fe564ead8d99f8355ac249f9b39316238\"\n}\n"
  },
  {
    "path": ".sqlx/query-67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH request_id_workspace_member_count AS (\\n        SELECT\\n          request_id,\\n          COUNT(*) AS member_count\\n        FROM af_access_request\\n        JOIN af_workspace_member USING (workspace_id)\\n        WHERE request_id = $1\\n        GROUP BY request_id\\n      )\\n      SELECT\\n      request_id,\\n      view_id,\\n      (\\n        workspace_id,\\n        af_workspace.database_storage_id,\\n        af_workspace.owner_uid,\\n        owner_profile.name,\\n        owner_profile.email,\\n        af_workspace.created_at,\\n        af_workspace.workspace_type,\\n        af_workspace.deleted_at,\\n        af_workspace.workspace_name,\\n        af_workspace.icon,\\n        request_id_workspace_member_count.member_count\\n      ) AS \\\"workspace!: AFWorkspaceWithMemberCountRow\\\",\\n      (\\n        af_user.uid,\\n        af_user.uuid,\\n        af_user.name,\\n        af_user.email,\\n        af_user.metadata ->> 'icon_url'\\n      ) AS \\\"requester!: AFAccessRequesterColumn\\\",\\n      status AS \\\"status: AFAccessRequestStatusColumn\\\",\\n      af_access_request.created_at AS created_at\\n      FROM af_access_request\\n      JOIN af_user USING (uid)\\n      JOIN af_workspace USING (workspace_id)\\n      JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\\n      JOIN request_id_workspace_member_count USING (request_id)\\n      WHERE request_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"request_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"workspace!: AFWorkspaceWithMemberCountRow\",\n        \"type_info\": \"Record\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"requester!: AFAccessRequesterColumn\",\n        \"type_info\": \"Record\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"status: AFAccessRequestStatusColumn\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      null,\n      null,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129\"\n}\n"
  },
  {
    "path": ".sqlx/query-6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_name\\n      FROM public.af_workspace\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"6821f1e02da2c71cdf0566a163c85ff185bf0ba89c770254c9c15880ba76a553\"\n}\n"
  },
  {
    "path": ".sqlx/query-6935572cb23700243fbbd3dc382cdbf56edaadc4aab7855c237bce68e29414c0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n       SELECT oid, blob\\n       FROM af_collab\\n       WHERE oid = ANY($1) AND deleted_at IS NULL;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"6935572cb23700243fbbd3dc382cdbf56edaadc4aab7855c237bce68e29414c0\"\n}\n"
  },
  {
    "path": ".sqlx/query-6aca3fde126cb1761c0a5ce1fbfa793bdbac4aed137cdf60eb3f277f36d7bf7a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      category_id AS id,\\n      name,\\n      description,\\n      icon,\\n      bg_color,\\n      category_type AS \\\"category_type: AFTemplateCategoryTypeColumn\\\",\\n      priority\\n    FROM af_template_category\\n    WHERE category_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"bg_color\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"category_type: AFTemplateCategoryTypeColumn\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"priority\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"6aca3fde126cb1761c0a5ce1fbfa793bdbac4aed137cdf60eb3f277f36d7bf7a\"\n}\n"
  },
  {
    "path": ".sqlx/query-6ca2a2fa10d5334183d98176998d41f36948fe5624e290a32d0b50bc9fb256bf.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        updated_at as updated_at,\\n        oid as row_id\\n      FROM af_collab\\n      WHERE workspace_id = $1\\n        AND oid = ANY($2)\\n        AND updated_at > $3\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"row_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"6ca2a2fa10d5334183d98176998d41f36948fe5624e290a32d0b50bc9fb256bf\"\n}\n"
  },
  {
    "path": ".sqlx/query-6cc4a7da11a37413c9951983ee3f30de933cc6357a66c8e10366fde27acaefea.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO af_blob_metadata\\n        (workspace_id, file_id, file_type, file_size)\\n        VALUES ($1, $2, $3, $4)\\n        ON CONFLICT (workspace_id, file_id) DO UPDATE SET\\n            file_type = $3,\\n            file_size = $4\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Varchar\",\n        \"Varchar\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"6cc4a7da11a37413c9951983ee3f30de933cc6357a66c8e10366fde27acaefea\"\n}\n"
  },
  {
    "path": ".sqlx/query-6f5d6d79587d7f7a52c920acccfe338a8c001ea30b722d3a6a1a60259d47913c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT content,meta_data\\n        FROM af_chat_messages\\n        WHERE message_id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"6f5d6d79587d7f7a52c920acccfe338a8c001ea30b722d3a6a1a60259d47913c\"\n}\n"
  },
  {
    "path": ".sqlx/query-6fbcd1c32c638530461c74f8c8195a5b1e1e6f7a389a6a60d889c88c5f47302a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT EXISTS (\\n            SELECT 1 FROM af_collab\\n            WHERE oid = $1 AND deleted_at IS NULL\\n        )\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"6fbcd1c32c638530461c74f8c8195a5b1e1e6f7a389a6a60d889c88c5f47302a\"\n}\n"
  },
  {
    "path": ".sqlx/query-71c15686124c05a4fdef066738eadd0ab17d6af1bfeffc480c8fe52a4e6edab8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT file_id FROM af_blob_metadata\\n    WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"file_id\",\n        \"type_info\": \"Varchar\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"71c15686124c05a4fdef066738eadd0ab17d6af1bfeffc480c8fe52a4e6edab8\"\n}\n"
  },
  {
    "path": ".sqlx/query-74de473589a405c3ab567e72a881869321095e2de497b2c1866c547f939c359c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT * FROM af_blob_metadata\\n        WHERE workspace_id = $1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"file_id\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"file_type\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"file_size\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"modified_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"source\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"source_metadata\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"74de473589a405c3ab567e72a881869321095e2de497b2c1866c547f939c359c\"\n}\n"
  },
  {
    "path": ".sqlx/query-75dc8578510ae696bf4bcdd780f7cefc666b4436cf53edf30a98dd2ff7926799.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        au.uuid,\\n        COALESCE(awmp.name, au.name) AS \\\"name!\\\",\\n        au.email,\\n        awm.role_id AS \\\"role!\\\",\\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \\\"avatar_url\\\",\\n        awmp.cover_image_url,\\n        awmp.custom_image_url,\\n        awmp.description\\n      FROM af_workspace_member awm\\n      JOIN af_user au ON awm.uid = au.uid\\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\\n      WHERE awm.workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"cover_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"custom_image_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null,\n      false,\n      false,\n      null,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"75dc8578510ae696bf4bcdd780f7cefc666b4436cf53edf30a98dd2ff7926799\"\n}\n"
  },
  {
    "path": ".sqlx/query-770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"UPDATE af_quick_note SET data = $1, updated_at = NOW() WHERE quick_note_id = $2\",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Jsonb\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"770a4979e137ca08c5ea625259221f9d397a56defb8e498eb92da7b3a8af612b\"\n}\n"
  },
  {
    "path": ".sqlx/query-786a59b28265397658aecf0318beeedece2a7f5bea80b9189f3989721035c593.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH user_workspace_id AS (\\n        SELECT workspace_id\\n        FROM af_workspace_member\\n        JOIN af_user ON af_workspace_member.uid = af_user.uid\\n        WHERE af_user.uuid = $1\\n      ),\\n      workspace_member_count AS (\\n        SELECT\\n          workspace_id,\\n          COUNT(*) AS member_count\\n        FROM af_workspace_member\\n        JOIN user_workspace_id USING (workspace_id)\\n        WHERE role_id != $2\\n        GROUP BY workspace_id\\n      )\\n\\n      SELECT\\n        w.workspace_id,\\n        w.database_storage_id,\\n        w.owner_uid,\\n        u.name AS owner_name,\\n        u.email AS owner_email,\\n        w.created_at,\\n        w.workspace_type,\\n        w.deleted_at,\\n        w.workspace_name,\\n        w.icon,\\n        wmc.member_count AS \\\"member_count!\\\",\\n        wm.role_id AS \\\"role!\\\"\\n      FROM af_workspace w\\n      JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\\n      JOIN public.af_user u ON w.owner_uid = u.uid\\n      JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\\n      WHERE wm.uid = (\\n         SELECT uid FROM public.af_user WHERE uuid = $1\\n      )\\n      AND COALESCE(w.is_initialized, true) = true;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"owner_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"owner_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"member_count!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      true,\n      false,\n      null,\n      false\n    ]\n  },\n  \"hash\": \"786a59b28265397658aecf0318beeedece2a7f5bea80b9189f3989721035c593\"\n}\n"
  },
  {
    "path": ".sqlx/query-78a191e21a7e7a07eee88ed02c7fbf7035f908b8e4057f7ace1b3b5d433424fe.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE af_collab\\n        SET deleted_at = $2\\n        WHERE oid = $1;\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"78a191e21a7e7a07eee88ed02c7fbf7035f908b8e4057f7ace1b3b5d433424fe\"\n}\n"
  },
  {
    "path": ".sqlx/query-794c4ced16801b3e98a62eb44c18c14137dd09b11be73442a7f46b2f938b8445.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT message_id, content, created_at, author, meta_data, reply_message_id\\n        FROM af_chat_messages\\n        WHERE chat_id = $1\\n        AND reply_message_id = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"message_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"author\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"reply_message_id\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"794c4ced16801b3e98a62eb44c18c14137dd09b11be73442a7f46b2f938b8445\"\n}\n"
  },
  {
    "path": ".sqlx/query-7a4c7da16e99ff3875bdd7e0d189e26c3c1ab49672bace41992aecc446061850.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SElECT settings FROM af_workspace WHERE workspace_id = $1\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"settings\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"7a4c7da16e99ff3875bdd7e0d189e26c3c1ab49672bace41992aecc446061850\"\n}\n"
  },
  {
    "path": ".sqlx/query-7a86f93afe6e77d4481920b08ed38926446f6473107d68dfcd82ffecddcee890.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        awn.namespace,\\n        apc.publish_name,\\n        apc.view_id,\\n        au.email AS publisher_email,\\n        apc.created_at AS publish_timestamp,\\n        apc.unpublished_at AS unpublished_timestamp,\\n        apc.comments_enabled,\\n        apc.duplicate_enabled\\n      FROM af_published_collab apc\\n      JOIN af_user au ON apc.published_by = au.uid\\n      JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\\n      JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\\n      WHERE apc.view_id = ANY($1);\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"namespace\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"publish_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"publisher_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"publish_timestamp\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"unpublished_timestamp\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comments_enabled\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"duplicate_enabled\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7a86f93afe6e77d4481920b08ed38926446f6473107d68dfcd82ffecddcee890\"\n}\n"
  },
  {
    "path": ".sqlx/query-7aa6e41c80f0b2906d46e73ae05e8e70e133b7edd450b102715b8a487d6055ac.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT metadata, blob\\n      FROM af_published_collab\\n      WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"metadata\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"7aa6e41c80f0b2906d46e73ae05e8e70e133b7edd450b102715b8a487d6055ac\"\n}\n"
  },
  {
    "path": ".sqlx/query-7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT name FROM af_user WHERE uuid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"7f6b1db5fd7b4e235f1e04d9d990fa2d47edfed23e692fbab778d387b2861a22\"\n}\n"
  },
  {
    "path": ".sqlx/query-811b6b01de4fdb06ad58185a5c49dfaa31aef8ea30ab3421d4afc13822fc0a9c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT c.oid, c.partition_key, c.updated_at, c.blob\\n        FROM af_collab c\\n        WHERE c.workspace_id = $1\\n            AND c.deleted_at IS NULL\\n            AND c.created_at > $2\\n        ORDER BY updated_at\\n        LIMIT $3\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"partition_key\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Timestamptz\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"811b6b01de4fdb06ad58185a5c49dfaa31aef8ea30ab3421d4afc13822fc0a9c\"\n}\n"
  },
  {
    "path": ".sqlx/query-816a026ca4c25329b2fb24d59efde9ab71798ff8b31ce7320e02344d4e8b3e42.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      DELETE FROM af_published_view_reaction\\n      WHERE comment_id = $1 AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) AND reaction_type = $3\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"816a026ca4c25329b2fb24d59efde9ab71798ff8b31ce7320e02344d4e8b3e42\"\n}\n"
  },
  {
    "path": ".sqlx/query-834638eb3c38eb2c220aa23ac928874d87606b47ef3bb80540614ce2f8453936.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_snapshot_state (oid, workspace_id, doc_state, doc_state_version, deps_snapshot_id, partition_key, created_at)\\n    VALUES ($1, $2, $3, $4, $5, $6, $7)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\",\n        \"Bytea\",\n        \"Int4\",\n        \"Uuid\",\n        \"Int4\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"834638eb3c38eb2c220aa23ac928874d87606b47ef3bb80540614ce2f8453936\"\n}\n"
  },
  {
    "path": ".sqlx/query-842243ea6ca59135ae539060ff37b80791e76aa268a44642ede515f315e80c01.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        DELETE FROM af_chat_messages\\n        WHERE message_id = $1\\n      \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"842243ea6ca59135ae539060ff37b80791e76aa268a44642ede515f315e80c01\"\n}\n"
  },
  {
    "path": ".sqlx/query-84c224af99f654e2e0ba11a411376794855483eedb0c30b1873ed660ca8d10cd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      uuid,\\n      name,\\n      metadata ->> 'icon_url' AS avatar_url\\n    FROM af_user\\n    WHERE uid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"84c224af99f654e2e0ba11a411376794855483eedb0c30b1873ed660ca8d10cd\"\n}\n"
  },
  {
    "path": ".sqlx/query-84e600f13d61c56a45133e7458d5152e68dec72030e5789bf4149a333b6ebdf5.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_template_view_template_category\\n    WHERE view_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"84e600f13d61c56a45133e7458d5152e68dec72030e5789bf4149a333b6ebdf5\"\n}\n"
  },
  {
    "path": ".sqlx/query-852c729791d5b5eb2dde5772ccbcd24579486e43886d95a11481991fdf28efa8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\\n      VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW())) ON CONFLICT (oid)\\n      DO UPDATE SET blob = $2, len = $3, owner_uid = $5, updated_at = COALESCE($7, NOW()) WHERE excluded.workspace_id = af_collab.workspace_id;\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Bytea\",\n        \"Int4\",\n        \"Int4\",\n        \"Int8\",\n        \"Uuid\",\n        \"Timestamptz\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"852c729791d5b5eb2dde5772ccbcd24579486e43886d95a11481991fdf28efa8\"\n}\n"
  },
  {
    "path": ".sqlx/query-85e9688218913dee85480932273ff6cf75d29af45638b195e73d73b6048806bf.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_workspace_member_profile (workspace_id, uid, name, avatar_url, cover_image_url, custom_image_url, description)\\n      VALUES ($1, $2, $3, $4, $5, $6, $7)\\n      ON CONFLICT (workspace_id, uid) DO UPDATE\\n      SET name = EXCLUDED.name,\\n          avatar_url = EXCLUDED.avatar_url,\\n          cover_image_url = EXCLUDED.cover_image_url,\\n          custom_image_url = EXCLUDED.custom_image_url,\\n          description = EXCLUDED.description\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"85e9688218913dee85480932273ff6cf75d29af45638b195e73d73b6048806bf\"\n}\n"
  },
  {
    "path": ".sqlx/query-865fe86df6d04f8abb6d477af13f8a2392a742f4027d99c290f0f156df48be07.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      fragment_id,\\n      content_type,\\n      content,\\n      embedding as \\\"embedding!: Option<Vector>\\\",\\n      metadata,\\n      fragment_index,\\n      embedder_type\\n    FROM af_collab_embeddings\\n    WHERE oid = $1\\n    ORDER BY fragment_index\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"fragment_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"content_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"embedding!: Option<Vector>\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"vector\",\n            \"kind\": \"Simple\"\n          }\n        }\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"metadata\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"fragment_index\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"embedder_type\",\n        \"type_info\": \"Int2\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      true,\n      true,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"865fe86df6d04f8abb6d477af13f8a2392a742f4027d99c290f0f156df48be07\"\n}\n"
  },
  {
    "path": ".sqlx/query-87628d6739441a22229d08832d09cbf4598c36204a6885b2e279c848cedcfa75.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT COUNT(*)\\n      FROM af_published_collab\\n      WHERE workspace_id = $1\\n        AND view_id = ANY($2)\\n        AND published_by = (SELECT uid FROM af_user WHERE uuid = $3)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"87628d6739441a22229d08832d09cbf4598c36204a6885b2e279c848cedcfa75\"\n}\n"
  },
  {
    "path": ".sqlx/query-88516b9a2a424bc7697337d6f16b0d6e94b919597d709f930467423c5b4c0ec2.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT * FROM af_collab_snapshot\\n      WHERE workspace_id = $1 AND oid = $2 AND deleted_at IS NULL\\n      ORDER BY created_at DESC\\n      LIMIT 1;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"sid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"oid\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"len\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"encrypt\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"88516b9a2a424bc7697337d6f16b0d6e94b919597d709f930467423c5b4c0ec2\"\n}\n"
  },
  {
    "path": ".sqlx/query-8cd79c307813a509119230c7673f86471463a06ad9a84764da8d5bb1e6168e1c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      af_user.uid,\\n      af_user.name,\\n      af_user.email,\\n      af_user.metadata ->> 'icon_url' AS avatar_url,\\n      af_workspace_member.role_id AS role,\\n      af_workspace_member.created_at\\n    FROM public.af_workspace_member\\n        JOIN public.af_user ON af_workspace_member.uid = af_user.uid\\n    WHERE af_workspace_member.workspace_id = $1\\n    AND role_id != $2\\n    ORDER BY af_workspace_member.created_at ASC;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"8cd79c307813a509119230c7673f86471463a06ad9a84764da8d5bb1e6168e1c\"\n}\n"
  },
  {
    "path": ".sqlx/query-90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id\\n      FROM af_workspace_invite_code\\n      WHERE invite_code = $1\\n        AND (expires_at IS NULL OR expires_at > NOW())\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"90a302af791eeb5c5f60c3f95145e0e73c2a1652c5b547e4118bac1d005300de\"\n}\n"
  },
  {
    "path": ".sqlx/query-90afca9cc8b6d4ca31e8ddf1ce466411b5034639df91b739f5cbe2af0ffb6811.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT oid, fragment_id\\n        FROM af_collab_embeddings\\n        WHERE oid = ANY($1::uuid[])\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"fragment_id\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"90afca9cc8b6d4ca31e8ddf1ce466411b5034639df91b739f5cbe2af0ffb6811\"\n}\n"
  },
  {
    "path": ".sqlx/query-92c4d0e22b1f6f117c9f19589832f5f89cb5b903eee3c12f5e5fc0f70f3236e1.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_published_collab\\n      SET\\n        blob = E''::bytea,\\n        unpublished_at = NOW()\\n      WHERE workspace_id = $1\\n        AND view_id = ANY($2)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"92c4d0e22b1f6f117c9f19589832f5f89cb5b903eee3c12f5e5fc0f70f3236e1\"\n}\n"
  },
  {
    "path": ".sqlx/query-936faba4e3c8fc3685d68f561a2c2d4f386c77cffde6f25702c19758a12669ce.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE af_workspace_member\\n        SET\\n            role_id = $1\\n        WHERE workspace_id = $2 AND uid = (\\n            SELECT uid FROM af_user WHERE email = $3\\n        )\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int4\",\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"936faba4e3c8fc3685d68f561a2c2d4f386c77cffde6f25702c19758a12669ce\"\n}\n"
  },
  {
    "path": ".sqlx/query-93f6a59171d7cd08d321c777f24255621280fbcf6a2c009afd601eac16c9ba3a.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT view_id\\n      FROM af_published_collab\\n      WHERE workspace_id = $1\\n      AND unpublished_at IS NULL\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"93f6a59171d7cd08d321c777f24255621280fbcf6a2c009afd601eac16c9ba3a\"\n}\n"
  },
  {
    "path": ".sqlx/query-94555a25b986992bd3cfb67bd36ff015d39bdd78ac20d56570306616bf10faf3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_published_collab (workspace_id, view_id, publish_name, published_by, metadata, blob, comments_enabled, duplicate_enabled)\\n      SELECT * FROM UNNEST(\\n        (SELECT array_agg((SELECT $1::uuid)) FROM generate_series(1, $9))::uuid[],\\n        $2::uuid[],\\n        $3::text[],\\n        (SELECT array_agg((SELECT uid FROM af_user WHERE uuid = $4)) FROM generate_series(1, $9))::bigint[],\\n        $5::jsonb[],\\n        $6::bytea[],\\n        $7::boolean[],\\n        $8::boolean[]\\n      )\\n      ON CONFLICT (workspace_id, view_id) DO UPDATE\\n      SET metadata = EXCLUDED.metadata,\\n          blob = EXCLUDED.blob,\\n          published_by = EXCLUDED.published_by,\\n          publish_name = EXCLUDED.publish_name\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\",\n        \"TextArray\",\n        \"Uuid\",\n        \"JsonbArray\",\n        \"ByteaArray\",\n        \"BoolArray\",\n        \"BoolArray\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"94555a25b986992bd3cfb67bd36ff015d39bdd78ac20d56570306616bf10faf3\"\n}\n"
  },
  {
    "path": ".sqlx/query-95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_access_request\\n      SET status = $2\\n      WHERE request_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"95b1b405028c45c074121110d046f42f8229f150c2384671802ee7c1ef9e376d\"\n}\n"
  },
  {
    "path": ".sqlx/query-95b4d7508569cac38c78d21a0a471772d3703e5678ee7ca0cd32d60f5343be91.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO af_chat_messages (chat_id, author, content)\\n        VALUES ($1, $2, $3)\\n        RETURNING message_id, created_at\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"message_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Jsonb\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"95b4d7508569cac38c78d21a0a471772d3703e5678ee7ca0cd32d60f5343be91\"\n}\n"
  },
  {
    "path": ".sqlx/query-95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        avc.comment_id,\\n        avc.created_at,\\n        avc.updated_at AS last_updated_at,\\n        avc.content,\\n        avc.reply_comment_id,\\n        avc.is_deleted,\\n        (au.uuid, au.name, au.email, au.metadata ->> 'icon_url') AS \\\"user: AFWebUserWithEmailColumn\\\",\\n        (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \\\"can_be_deleted!\\\"\\n      FROM af_published_view_comment avc\\n      LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\\n      WHERE view_id = $1\\n      ORDER BY avc.created_at DESC\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"last_updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"reply_comment_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"is_deleted\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"user: AFWebUserWithEmailColumn\",\n        \"type_info\": \"Record\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"can_be_deleted!\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Bool\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12\"\n}\n"
  },
  {
    "path": ".sqlx/query-9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_published_view_comment (view_id, created_by, content, reply_comment_id)\\n      VALUES ($1, (SELECT uid FROM af_user WHERE uuid = $2), $3, $4)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\",\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"9ab1ff2abc6d51bc5a48a1dc6c294bbfdbe0d5f11a5e2ffc8c1973217b80307b\"\n}\n"
  },
  {
    "path": ".sqlx/query-9b2a8297fa991418b255fc5cb6ad70d695c4dceed20bdc557bfedfc820511126.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    UPDATE af_template_category\\n    SET\\n      name = $2,\\n      description = $3,\\n      icon = $4,\\n      bg_color = $5,\\n      category_type = $6,\\n      priority = $7,\\n      updated_at = NOW()\\n    WHERE category_id = $1\\n    RETURNING\\n      category_id AS id,\\n      name,\\n      description,\\n      icon,\\n      bg_color,\\n      category_type AS \\\"category_type: AFTemplateCategoryTypeColumn\\\",\\n      priority\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"bg_color\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"category_type: AFTemplateCategoryTypeColumn\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"priority\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Int4\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"9b2a8297fa991418b255fc5cb6ad70d695c4dceed20bdc557bfedfc820511126\"\n}\n"
  },
  {
    "path": ".sqlx/query-a18d0c9536dba734715903c8e8f0b7be30d3e7a477c4ddd03533b781df2fb2c7.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        awn.namespace,\\n        apc.publish_name,\\n        apc.view_id,\\n        au.email AS publisher_email,\\n        apc.created_at AS publish_timestamp,\\n        apc.unpublished_at AS unpublished_timestamp,\\n        apc.comments_enabled,\\n        apc.duplicate_enabled\\n      FROM af_published_collab apc\\n      JOIN af_user au ON apc.published_by = au.uid\\n      JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\\n      JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\\n      WHERE apc.workspace_id = $1 AND apc.unpublished_at IS NULL;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"namespace\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"publish_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"publisher_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"publish_timestamp\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"unpublished_timestamp\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"comments_enabled\",\n        \"type_info\": \"Bool\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"duplicate_enabled\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"a18d0c9536dba734715903c8e8f0b7be30d3e7a477c4ddd03533b781df2fb2c7\"\n}\n"
  },
  {
    "path": ".sqlx/query-a3ab30d48e4a10aff1fbfa9dbc5d275a06598610bc471893c8c0febfc36c4737.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id > $2)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"a3ab30d48e4a10aff1fbfa9dbc5d275a06598610bc471893c8c0febfc36c4737\"\n}\n"
  },
  {
    "path": ".sqlx/query-a3c235bd5df50f80ec93c3d9f6da8db7e17e89788f30c5b6432c582992b6a009.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      DELETE FROM af_published_collab\\n      WHERE workspace_id = $1\\n        AND publish_name = ANY($2::text[])\\n      RETURNING publish_name\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"publish_name\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"TextArray\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"a3c235bd5df50f80ec93c3d9f6da8db7e17e89788f30c5b6432c582992b6a009\"\n}\n"
  },
  {
    "path": ".sqlx/query-a527a90fcb69c58a5e711555b6ee56e7b92ceabe746279eccd7ae3e9fa918e96.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      af_user.uid,\\n      af_user.name,\\n      af_user.email,\\n      af_user.metadata ->> 'icon_url' AS avatar_url,\\n      af_workspace_member.role_id AS role,\\n      af_workspace_member.created_at\\n    FROM public.af_workspace_member\\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\\n    WHERE af_workspace_member.workspace_id = $1\\n    AND af_workspace_member.uid = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"a527a90fcb69c58a5e711555b6ee56e7b92ceabe746279eccd7ae3e9fa918e96\"\n}\n"
  },
  {
    "path": ".sqlx/query-a75bf8b11d832d154716d4618595b117da583a31b51baaf7b84e9ee0d0e3109c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        person_id\\n      FROM af_page_mention\\n      WHERE workspace_id = $1\\n        AND mentioned_by = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"person_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"a75bf8b11d832d154716d4618595b117da583a31b51baaf7b84e9ee0d0e3109c\"\n}\n"
  },
  {
    "path": ".sqlx/query-a7c03becdf9954611ac7ad96e1f5bb5e8364f095f1cc4dc23719b218eb032973.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT deleted_at IS NOT NULL AS is_deleted\\n        FROM af_collab\\n        WHERE oid = $1;\\n      \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"is_deleted\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"a7c03becdf9954611ac7ad96e1f5bb5e8364f095f1cc4dc23719b218eb032973\"\n}\n"
  },
  {
    "path": ".sqlx/query-aa75996ca6aa12f0bcaa5fb092ac279f8a94aadcc29d0e2b652dc420506835e7.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, role_id\\n      FROM af_workspace_member\\n      WHERE workspace_id = ANY($1)\\n        AND uid = (SELECT uid FROM public.af_user WHERE uuid = $2)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"role_id\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false\n    ]\n  },\n  \"hash\": \"aa75996ca6aa12f0bcaa5fb092ac279f8a94aadcc29d0e2b652dc420506835e7\"\n}\n"
  },
  {
    "path": ".sqlx/query-b16f38d563d4d0b35f06978a8b2c76dc5121b0e59f8b5992c9dad05dd101c8ad.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        i.id AS invite_id,\\n        i.workspace_id,\\n        w.workspace_name,\\n        u_inviter.email AS inviter_email,\\n        u_inviter.name AS inviter_name,\\n        i.status,\\n        i.updated_at,\\n        u_inviter.metadata->>'icon_url' AS inviter_icon,\\n        w.icon AS workspace_icon,\\n        (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\\n      FROM\\n        public.af_workspace_invitation i\\n        JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\\n        JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\\n        JOIN public.af_user u_invitee ON u_invitee.uuid = $1\\n      WHERE\\n        LOWER(i.invitee_email) = LOWER(u_invitee.email)\\n        AND ($2::SMALLINT IS NULL OR i.status = $2);\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"invite_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"inviter_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"inviter_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"inviter_icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"member_count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int2\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      null,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"b16f38d563d4d0b35f06978a8b2c76dc5121b0e59f8b5992c9dad05dd101c8ad\"\n}\n"
  },
  {
    "path": ".sqlx/query-b5024138772e13557df973c1c021daf74aab97b5874d7366c478c18ae2e89e58.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT uid FROM af_user WHERE email = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"b5024138772e13557df973c1c021daf74aab97b5874d7366c478c18ae2e89e58\"\n}\n"
  },
  {
    "path": ".sqlx/query-b509712055858af398fd12ddd1a8c3da54280cf55f0c53f340bddbf4bf09b3e0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_related_template_view (view_id, related_view_id)\\n    SELECT $1 AS view_id, related_view_id\\n    FROM UNNEST($2::uuid[]) AS t(related_view_id)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"b509712055858af398fd12ddd1a8c3da54280cf55f0c53f340bddbf4bf09b3e0\"\n}\n"
  },
  {
    "path": ".sqlx/query-ba815f67aab3f302a2982225b72c6113bbd9bc87326e4f0a3b44dadbb5f47920.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT uid, role_id as role, workspace_id FROM af_workspace_member\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": []\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ba815f67aab3f302a2982225b72c6113bbd9bc87326e4f0a3b44dadbb5f47920\"\n}\n"
  },
  {
    "path": ".sqlx/query-bbb3c31ea7e9c0a3bdabbc23b2730ee0254f38a7c1457f917c8f37f1e1aefa12.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        UPDATE af_chat_messages\\n        SET reply_message_id = $2\\n        WHERE message_id = $1\\n      \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"bbb3c31ea7e9c0a3bdabbc23b2730ee0254f38a7c1457f917c8f37f1e1aefa12\"\n}\n"
  },
  {
    "path": ".sqlx/query-bd34e351ea1adc0d12d4f1cce5a855089b7f39a431dea2903c3e0b9a220640b8.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH creator_number_of_templates AS (\\n      SELECT\\n        creator_id,\\n        COUNT(1)::int AS number_of_templates\\n      FROM af_template_view\\n      WHERE creator_id = $1\\n      GROUP BY creator_id\\n    )\\n    SELECT\\n      creator.creator_id AS \\\"id!\\\",\\n      name AS \\\"name!\\\",\\n      avatar_url AS \\\"avatar_url!\\\",\\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \\\"account_links: Vec<AccountLinkColumn>\\\",\\n      COALESCE(number_of_templates, 0) AS \\\"number_of_templates!\\\"\\n    FROM af_template_creator creator\\n    LEFT OUTER JOIN af_template_creator_account_link account_link\\n    USING (creator_id)\\n    LEFT OUTER JOIN creator_number_of_templates\\n    USING (creator_id)\\n    WHERE creator.creator_id = $1\\n    GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id!\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"avatar_url!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"account_links: Vec<AccountLinkColumn>\",\n        \"type_info\": \"RecordArray\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"number_of_templates!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      null\n    ]\n  },\n  \"hash\": \"bd34e351ea1adc0d12d4f1cce5a855089b7f39a431dea2903c3e0b9a220640b8\"\n}\n"
  },
  {
    "path": ".sqlx/query-bde2b88ffb1b59362c7ae82369892c79131c175924f95e5d48d75931fb846f41.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT email FROM af_user WHERE uuid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"bde2b88ffb1b59362c7ae82369892c79131c175924f95e5d48d75931fb846f41\"\n}\n"
  },
  {
    "path": ".sqlx/query-bf9bff5c65ba051329ed2b694eff62808f971a8262b6e1649d91526ab3a3870d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id, namespace, is_original\\n      FROM af_workspace_namespace\\n      WHERE workspace_id = $1\\n        AND namespace = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"namespace\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"is_original\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"bf9bff5c65ba051329ed2b694eff62808f971a8262b6e1649d91526ab3a3870d\"\n}\n"
  },
  {
    "path": ".sqlx/query-c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM af_template_creator\\n    WHERE creator_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c335b73ad499b67100e4ce3131a526ddf1745488597c3392ae05e4b398a8715e\"\n}\n"
  },
  {
    "path": ".sqlx/query-c360ec37792d567535ccd2a5011d92c7a201f516e92e204db855167f381c58b1.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        workspace_id,\\n        database_storage_id,\\n        owner_uid,\\n        owner_profile.name as owner_name,\\n        owner_profile.email as owner_email,\\n        af_workspace.created_at,\\n        workspace_type,\\n        af_workspace.deleted_at,\\n        workspace_name,\\n        icon\\n      FROM public.af_workspace\\n      JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\\n      WHERE af_workspace.workspace_id = $1\\n        AND COALESCE(af_workspace.is_initialized, true) = true;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"owner_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"owner_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"c360ec37792d567535ccd2a5011d92c7a201f516e92e204db855167f381c58b1\"\n}\n"
  },
  {
    "path": ".sqlx/query-c43d414f6fcaed34e059f55abaaa0bd1343cacf4d04e98481a4787a4b965ce94.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_workspace_namespace\\n      SET namespace = $1\\n      WHERE workspace_id = $2\\n        AND namespace = $3\\n        AND is_original = FALSE\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c43d414f6fcaed34e059f55abaaa0bd1343cacf4d04e98481a4787a4b965ce94\"\n}\n"
  },
  {
    "path": ".sqlx/query-c81848346ed2ff85f1d5fb8041fba648137a927762b385b97054552c00793a50.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      DELETE FROM af_workspace_invite_code\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c81848346ed2ff85f1d5fb8041fba648137a927762b385b97054552c00793a50\"\n}\n"
  },
  {
    "path": ".sqlx/query-c843fb8517b1e364016b85a9e94927673bf8311bfbf723b610d59ecfef3fafce.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    DELETE FROM public.af_workspace_member\\n    WHERE\\n    workspace_id = $1\\n    AND uid = (\\n        SELECT uid FROM public.af_user WHERE email = $2\\n    )\\n    -- Ensure the user to be deleted is not the original owner.\\n    -- 1. TODO(nathan): User must transfer ownership to another user first.\\n    -- 2. User must have at least one workspace\\n    AND uid <> (\\n        SELECT owner_uid FROM public.af_workspace WHERE workspace_id = $1\\n    );\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"c843fb8517b1e364016b85a9e94927673bf8311bfbf723b610d59ecfef3fafce\"\n}\n"
  },
  {
    "path": ".sqlx/query-c8b1f57c5ddce8006a8e137be07f13b455f59657f5fcef67d69905ecec4cb063.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        w.workspace_name AS \\\"workspace_name!\\\",\\n        pm.workspace_id,\\n        pm.view_id,\\n        pm.view_name,\\n        mentioner.name AS \\\"mentioner_name!\\\",\\n        mentioner.metadata ->> 'icon_url' AS \\\"mentioner_avatar_url\\\",\\n        pm.person_id AS \\\"mentioned_person_id\\\",\\n        mentioned_person.name AS \\\"mentioned_person_name!\\\",\\n        mentioned_person.email AS \\\"mentioned_person_email!\\\",\\n        pm.mentioned_at AS \\\"mentioned_at!\\\",\\n        pm.block_id\\n      FROM af_page_mention AS pm\\n      JOIN af_workspace AS w ON pm.workspace_id = w.workspace_id\\n      JOIN af_user AS mentioned_person\\n        ON pm.person_id = mentioned_person.uuid\\n      JOIN af_user AS mentioner\\n        ON pm.mentioned_by = mentioner.uid\\n      WHERE pm.mentioned_at > NOW() - $1::INTERVAL\\n      AND require_notification\\n      AND NOT notified\\n      FOR UPDATE SKIP LOCKED\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"view_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"mentioner_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"mentioner_avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"mentioned_person_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"mentioned_person_name!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"mentioned_person_email!\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"mentioned_at!\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"block_id\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Interval\"\n      ]\n    },\n    \"nullable\": [\n      true,\n      false,\n      false,\n      false,\n      false,\n      null,\n      false,\n      false,\n      false,\n      true,\n      true\n    ]\n  },\n  \"hash\": \"c8b1f57c5ddce8006a8e137be07f13b455f59657f5fcef67d69905ecec4cb063\"\n}\n"
  },
  {
    "path": ".sqlx/query-ca2a21db67716e3f12b9f9240c1dba1b7cbe0bec1f59ef132fed53942ebad317.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_template_view (\\n      view_id,\\n      name,\\n      description,\\n      about,\\n      view_url,\\n      creator_id,\\n      is_new_template,\\n      is_featured\\n    )\\n    VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Uuid\",\n        \"Bool\",\n        \"Bool\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"ca2a21db67716e3f12b9f9240c1dba1b7cbe0bec1f59ef132fed53942ebad317\"\n}\n"
  },
  {
    "path": ".sqlx/query-cb2375ad0094baefed417645b781f40dcabfbfe4a4738c99bb4efff649e6a0e6.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_template_category (name, description, icon, bg_color, category_type, priority)\\n    VALUES ($1, $2, $3, $4, $5, $6)\\n    RETURNING\\n      category_id AS id,\\n      name,\\n      description,\\n      icon,\\n      bg_color,\\n      category_type AS \\\"category_type: AFTemplateCategoryTypeColumn\\\",\\n      priority\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"bg_color\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"category_type: AFTemplateCategoryTypeColumn\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"priority\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Text\",\n        \"Int4\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cb2375ad0094baefed417645b781f40dcabfbfe4a4738c99bb4efff649e6a0e6\"\n}\n"
  },
  {
    "path": ".sqlx/query-cbe8402053d42529dce158b446d09a00982e1d7cdc33835776bfbefb4b4c1854.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT view_id\\n      FROM af_published_collab\\n      WHERE workspace_id = $1\\n        AND unpublished_at IS NULL\\n        AND publish_name = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"view_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"cbe8402053d42529dce158b446d09a00982e1d7cdc33835776bfbefb4b4c1854\"\n}\n"
  },
  {
    "path": ".sqlx/query-cbf1d3d9fdeb672eacd4b008879787bc1f0b22a554fb249d4e12a665d9767cbd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n          ac.oid as object_id,\\n          ace.partition_key,\\n          ac.indexed_at,\\n          ace.updated_at\\n      FROM af_collab_embeddings ac\\n      JOIN af_collab ace ON ac.oid = ace.oid\\n      WHERE ac.oid = ANY($1)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"object_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"partition_key\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"indexed_at\",\n        \"type_info\": \"Timestamp\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"cbf1d3d9fdeb672eacd4b008879787bc1f0b22a554fb249d4e12a665d9767cbd\"\n}\n"
  },
  {
    "path": ".sqlx/query-cce2abeed3399ad0b8867901735c5883c8d35fa82d6e0596c56eaf02c36a7e4f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT oid, deleted_at IS NOT NULL AS is_deleted\\n        FROM af_collab\\n        WHERE oid = ANY($1);\\n      \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"is_deleted\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      null\n    ]\n  },\n  \"hash\": \"cce2abeed3399ad0b8867901735c5883c8d35fa82d6e0596c56eaf02c36a7e4f\"\n}\n"
  },
  {
    "path": ".sqlx/query-d0a24b554fe420d7ebf856ae7f1525aff3695fc97e2f43041dc54a4e62a88746.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        DELETE FROM af_blob_metadata\\n        WHERE workspace_id = $1 AND file_id = $2\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d0a24b554fe420d7ebf856ae7f1525aff3695fc97e2f43041dc54a4e62a88746\"\n}\n"
  },
  {
    "path": ".sqlx/query-d0e5f5097b35a15f19e9e7faf2c62336d5f130e939331e84c7d834f6028ea673.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_collab\\n      SET indexed_at = $1\\n      WHERE oid = $2 AND partition_key = $3\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Timestamptz\",\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d0e5f5097b35a15f19e9e7faf2c62336d5f130e939331e84c7d834f6028ea673\"\n}\n"
  },
  {
    "path": ".sqlx/query-d1ab621e0b6e8bc24f8fa8cbb975ae3b7f9f366cac02d66b5291d7207295ca29.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT message_id, content, created_at, author, meta_data, reply_message_id\\n        FROM af_chat_messages\\n        WHERE message_id = $1\\n      \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"message_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"author\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"reply_message_id\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"d1ab621e0b6e8bc24f8fa8cbb975ae3b7f9f366cac02d66b5291d7207295ca29\"\n}\n"
  },
  {
    "path": ".sqlx/query-d1f845717b19636e61d1d96d7a5629754f3ded9bda9116953bd1b40bd80551ae.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\\n        VALUES ($1, $2, $3, $4, $5)\\n        \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Bytea\",\n        \"Int4\",\n        \"Int4\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d1f845717b19636e61d1d96d7a5629754f3ded9bda9116953bd1b40bd80551ae\"\n}\n"
  },
  {
    "path": ".sqlx/query-d2e87c077e5702cd57a88e23e1eabe4b0badd98ef99da1b185bffa8d5c9ed298.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id < $2)\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d2e87c077e5702cd57a88e23e1eabe4b0badd98ef99da1b185bffa8d5c9ed298\"\n}\n"
  },
  {
    "path": ".sqlx/query-d366aca6b187f086e5a8281081adec190bbb3cd5256c5a77ed321b99cd34bbbc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      af_user.uid,\\n      af_user.name,\\n      af_user.email,\\n      af_user.metadata ->> 'icon_url' AS avatar_url,\\n      af_workspace_member.role_id AS role,\\n      af_workspace_member.created_at\\n    FROM public.af_workspace_member\\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\\n    WHERE af_workspace_member.workspace_id = $1\\n    AND af_user.uuid = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"d366aca6b187f086e5a8281081adec190bbb3cd5256c5a77ed321b99cd34bbbc\"\n}\n"
  },
  {
    "path": ".sqlx/query-d388782f755f0b164ef36c168af142baeb9bbd3cc2b8b7cd736b346580be8790.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT EXISTS (SELECT 1 FROM af_collab WHERE oid = $1 LIMIT 1)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d388782f755f0b164ef36c168af142baeb9bbd3cc2b8b7cd736b346580be8790\"\n}\n"
  },
  {
    "path": ".sqlx/query-d492c20dec54c7335744dcc139b95f30a80f06d9fd48de644630adf183e1ac34.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT COUNT(*)\\n      FROM public.af_workspace_member\\n      WHERE workspace_id = $1\\n      AND role_id != $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d492c20dec54c7335744dcc139b95f30a80f06d9fd48de644630adf183e1ac34\"\n}\n"
  },
  {
    "path": ".sqlx/query-d4fa2c5f3c455be4694235009e82efdd99d366e3b0374f78efec8dd560f88d95.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT uid\\n      FROM af_workspace_member\\n      WHERE workspace_id = $1\\n      ORDER BY created_at ASC\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"d4fa2c5f3c455be4694235009e82efdd99d366e3b0374f78efec8dd560f88d95\"\n}\n"
  },
  {
    "path": ".sqlx/query-d61523de25986b47a382d36a1f18e590420f1b1285d024f5554cc02c375d6476.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT EXISTS(\\n      SELECT 1\\n      FROM af_user\\n      WHERE uuid = $1\\n    ) AS user_exists;\\n  \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"user_exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"d61523de25986b47a382d36a1f18e590420f1b1285d024f5554cc02c375d6476\"\n}\n"
  },
  {
    "path": ".sqlx/query-d756ec630d5b75dd0dc7df2339847e28bdf07a790e65fd40a64d7f9022f430bd.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE public.af_workspace\\n      SET icon = $1\\n      WHERE workspace_id = $2\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d756ec630d5b75dd0dc7df2339847e28bdf07a790e65fd40a64d7f9022f430bd\"\n}\n"
  },
  {
    "path": ".sqlx/query-d84ab58e78653688e7c392ffad00d6e039be5ccb9c5b99b7088cc41cfe981873.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n     SELECT message_id, content, created_at, author, meta_data, reply_message_id\\n          FROM af_chat_messages\\n          WHERE chat_id = $1\\n          ORDER BY created_at ASC\\n   \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"message_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"content\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"author\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"reply_message_id\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"d84ab58e78653688e7c392ffad00d6e039be5ccb9c5b99b7088cc41cfe981873\"\n}\n"
  },
  {
    "path": ".sqlx/query-d90e7efaca54b92de038b6eef20a7bd36be747dc38f7943fe299799c623038be.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT\\n      af_user.uid,\\n      af_user.name,\\n      af_user.email,\\n      af_user.metadata ->> 'icon_url' AS avatar_url,\\n      af_workspace_member.role_id AS role,\\n      af_workspace_member.created_at\\n    FROM public.af_workspace_member\\n    JOIN public.af_workspace USING(workspace_id)\\n    JOIN public.af_user ON af_workspace.owner_uid = af_user.uid\\n    WHERE af_workspace_member.workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"avatar_url\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"role\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      null,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"d90e7efaca54b92de038b6eef20a7bd36be747dc38f7943fe299799c623038be\"\n}\n"
  },
  {
    "path": ".sqlx/query-d921f52e4bc3fef72c810e19455a2fa4fbd52f5a1f3a1838b146d001eadabd47.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n       UPDATE af_workspace_member\\n       SET updated_at = CURRENT_TIMESTAMP\\n       WHERE uid = (SELECT uid FROM public.af_user WHERE uuid = $1) AND workspace_id = $2;\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"d921f52e4bc3fef72c810e19455a2fa4fbd52f5a1f3a1838b146d001eadabd47\"\n}\n"
  },
  {
    "path": ".sqlx/query-da1434fe116cbb48bc5aac0b6905dd748f096bf78d3cdcfea3a576b4aaeba5fc.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n         UPDATE af_chat_messages\\n         SET content = $2,\\n             author = $3,\\n             created_at = CURRENT_TIMESTAMP,\\n             meta_data = $4\\n         WHERE message_id = $1\\n      \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Text\",\n        \"Jsonb\",\n        \"Jsonb\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"da1434fe116cbb48bc5aac0b6905dd748f096bf78d3cdcfea3a576b4aaeba5fc\"\n}\n"
  },
  {
    "path": ".sqlx/query-dbc31936b3e79632f9c8bae449182274d9d75766bd9a5c383b96bd60e9c5c866.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT rag_ids\\n        FROM af_chat\\n        WHERE chat_id = $1 AND deleted_at IS NULL\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"rag_ids\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"dbc31936b3e79632f9c8bae449182274d9d75766bd9a5c383b96bd60e9c5c866\"\n}\n"
  },
  {
    "path": ".sqlx/query-dc600fc160b55be22fb77e285fd7e5e646ef359fdbca9b62c6aefede5ebff606.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH recent_template AS (\\n        SELECT\\n          template_template_category.category_id,\\n          template_template_category.view_id,\\n          category.name,\\n          category.icon,\\n          category.bg_color,\\n          ROW_NUMBER() OVER (PARTITION BY template_template_category.category_id ORDER BY template.created_at DESC) AS recency\\n        FROM af_template_view_template_category template_template_category\\n        JOIN af_template_category category\\n        USING (category_id)\\n        JOIN af_template_view template\\n        USING (view_id)\\n        JOIN af_published_collab\\n        USING (view_id)\\n      ),\\n      template_group_by_category_and_view AS (\\n        SELECT\\n          category_id,\\n          view_id,\\n          ARRAY_AGG((\\n            category_id,\\n            name,\\n            icon,\\n            bg_color\\n          )::template_category_minimal_type) AS categories\\n          FROM recent_template\\n          WHERE recency <= $1\\n          GROUP BY category_id, view_id\\n      ),\\n      template_group_by_category_and_view_with_creator_and_template_details AS (\\n        SELECT\\n          template_group_by_category_and_view.category_id,\\n          (\\n            template.view_id,\\n            template.created_at,\\n            template.updated_at,\\n            template.name,\\n            template.description,\\n            template.view_url,\\n            (\\n              creator.creator_id,\\n              creator.name,\\n              creator.avatar_url\\n            )::template_creator_minimal_type,\\n            template_group_by_category_and_view.categories,\\n            template.is_new_template,\\n            template.is_featured\\n          )::template_minimal_type AS template\\n        FROM template_group_by_category_and_view\\n        JOIN af_template_view template\\n        USING (view_id)\\n        JOIN af_template_creator creator\\n        USING (creator_id)\\n      ),\\n      template_group_by_category AS (\\n        SELECT\\n          category_id,\\n          ARRAY_AGG(template) AS templates\\n        FROM template_group_by_category_and_view_with_creator_and_template_details\\n        GROUP BY category_id\\n      )\\n      SELECT\\n        (\\n          template_group_by_category.category_id,\\n          category.name,\\n          category.icon,\\n          category.bg_color\\n        )::template_category_minimal_type AS \\\"category!: AFTemplateCategoryMinimalRow\\\",\\n        templates AS \\\"templates!: Vec<AFTemplateMinimalRow>\\\"\\n        FROM template_group_by_category\\n        JOIN af_template_category category\\n        USING (category_id)\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"category!: AFTemplateCategoryMinimalRow\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"template_category_minimal_type\",\n            \"kind\": {\n              \"Composite\": [\n                [\n                  \"category_id\",\n                  \"Uuid\"\n                ],\n                [\n                  \"name\",\n                  \"Text\"\n                ],\n                [\n                  \"icon\",\n                  \"Text\"\n                ],\n                [\n                  \"bg_color\",\n                  \"Text\"\n                ]\n              ]\n            }\n          }\n        }\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"templates!: Vec<AFTemplateMinimalRow>\",\n        \"type_info\": {\n          \"Custom\": {\n            \"name\": \"template_minimal_type[]\",\n            \"kind\": {\n              \"Array\": {\n                \"Custom\": {\n                  \"name\": \"template_minimal_type\",\n                  \"kind\": {\n                    \"Composite\": [\n                      [\n                        \"view_id\",\n                        \"Uuid\"\n                      ],\n                      [\n                        \"created_at\",\n                        \"Timestamptz\"\n                      ],\n                      [\n                        \"updated_at\",\n                        \"Timestamptz\"\n                      ],\n                      [\n                        \"name\",\n                        \"Text\"\n                      ],\n                      [\n                        \"description\",\n                        \"Text\"\n                      ],\n                      [\n                        \"view_url\",\n                        \"Text\"\n                      ],\n                      [\n                        \"creator\",\n                        {\n                          \"Custom\": {\n                            \"name\": \"template_creator_minimal_type\",\n                            \"kind\": {\n                              \"Composite\": [\n                                [\n                                  \"creator_id\",\n                                  \"Uuid\"\n                                ],\n                                [\n                                  \"name\",\n                                  \"Text\"\n                                ],\n                                [\n                                  \"avatar_url\",\n                                  \"Text\"\n                                ]\n                              ]\n                            }\n                          }\n                        }\n                      ],\n                      [\n                        \"categories\",\n                        {\n                          \"Custom\": {\n                            \"name\": \"template_category_minimal_type[]\",\n                            \"kind\": {\n                              \"Array\": {\n                                \"Custom\": {\n                                  \"name\": \"template_category_minimal_type\",\n                                  \"kind\": {\n                                    \"Composite\": [\n                                      [\n                                        \"category_id\",\n                                        \"Uuid\"\n                                      ],\n                                      [\n                                        \"name\",\n                                        \"Text\"\n                                      ],\n                                      [\n                                        \"icon\",\n                                        \"Text\"\n                                      ],\n                                      [\n                                        \"bg_color\",\n                                        \"Text\"\n                                      ]\n                                    ]\n                                  }\n                                }\n                              }\n                            }\n                          }\n                        }\n                      ],\n                      [\n                        \"is_new_template\",\n                        \"Bool\"\n                      ],\n                      [\n                        \"is_featured\",\n                        \"Bool\"\n                      ]\n                    ]\n                  }\n                }\n              }\n            }\n          }\n        }\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      null,\n      null\n    ]\n  },\n  \"hash\": \"dc600fc160b55be22fb77e285fd7e5e646ef359fdbca9b62c6aefede5ebff606\"\n}\n"
  },
  {
    "path": ".sqlx/query-e219696c80f1d4c38260ebeb50ec78e344975eef6760951dbf6201c01b8ceef0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    UPDATE public.af_workspace_invitation\\n    SET status = 1\\n    WHERE LOWER(invitee_email) = (SELECT LOWER(email) FROM public.af_user WHERE uuid = $1)\\n      AND id = $2\\n      AND status = 0\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"e219696c80f1d4c38260ebeb50ec78e344975eef6760951dbf6201c01b8ceef0\"\n}\n"
  },
  {
    "path": ".sqlx/query-e2b4d66736962d1e3d0b9cf687ce5c5e653b465462f53433a28cf314e5c87d6c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    WITH ins_user AS (\\n        INSERT INTO af_user (uid, uuid, email, name)\\n        VALUES ($1, $2, $3, $4)\\n        RETURNING uid\\n    ),\\n    owner_role AS (\\n        SELECT id FROM af_roles WHERE name = 'Owner'\\n    ),\\n    ins_workspace AS (\\n        INSERT INTO af_workspace (owner_uid)\\n        SELECT uid FROM ins_user\\n        RETURNING workspace_id, owner_uid\\n    )\\n    SELECT workspace_id FROM ins_workspace;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\",\n        \"Uuid\",\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"e2b4d66736962d1e3d0b9cf687ce5c5e653b465462f53433a28cf314e5c87d6c\"\n}\n"
  },
  {
    "path": ".sqlx/query-e38e66d89806471f358b317778de35a68da4b9e6ca6e4b6a7c437ca7493b858c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT uuid FROM af_user WHERE uid = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"uuid\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"e38e66d89806471f358b317778de35a68da4b9e6ca6e4b6a7c437ca7493b858c\"\n}\n"
  },
  {
    "path": ".sqlx/query-e6159a03f1521b44de59858cd95c48e62cabefba6cac629c104eec75d2868bf3.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      WITH user_workspace_id AS (\\n        SELECT workspace_id\\n        FROM af_workspace_member\\n        JOIN af_user ON af_workspace_member.uid = af_user.uid\\n        WHERE af_user.uuid = $1\\n      ),\\n      workspace_member_count AS (\\n        SELECT\\n          workspace_id,\\n          COUNT(*) AS member_count\\n        FROM af_workspace_member\\n        JOIN user_workspace_id USING (workspace_id)\\n        WHERE role_id != $2\\n        GROUP BY workspace_id\\n      )\\n\\n      SELECT\\n        w.workspace_id,\\n        w.database_storage_id,\\n        w.owner_uid,\\n        u.name AS owner_name,\\n        u.email AS owner_email,\\n        w.created_at,\\n        w.workspace_type,\\n        w.deleted_at,\\n        w.workspace_name,\\n        w.icon,\\n        wmc.member_count AS \\\"member_count!\\\",\\n        wm.role_id AS \\\"role!\\\"\\n      FROM af_workspace w\\n      JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\\n      JOIN public.af_user u ON w.owner_uid = u.uid\\n      JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\\n      WHERE wm.uid = (\\n         SELECT uid FROM public.af_user WHERE uuid = $1\\n      )\\n      AND wm.role_id != $2\\n      AND COALESCE(w.is_initialized, true) = true;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"database_storage_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"owner_uid\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"owner_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"owner_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"workspace_type\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 10,\n        \"name\": \"member_count!\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 11,\n        \"name\": \"role!\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      false,\n      true,\n      false,\n      true,\n      true,\n      false,\n      null,\n      false\n    ]\n  },\n  \"hash\": \"e6159a03f1521b44de59858cd95c48e62cabefba6cac629c104eec75d2868bf3\"\n}\n"
  },
  {
    "path": ".sqlx/query-e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_published_view_comment\\n      SET is_deleted = true\\n      WHERE comment_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"e6a0e771ffacfdec95ef8c36de769448384fda4350aa630becebd0e5add632f4\"\n}\n"
  },
  {
    "path": ".sqlx/query-ea239353f73904400915ec89640ac71985a8d5b39037f567a3e2ac1c5eea8f64.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_workspace_namespace\\n      VALUES ($1, $2, FALSE)\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"ea239353f73904400915ec89640ac71985a8d5b39037f567a3e2ac1c5eea8f64\"\n}\n"
  },
  {
    "path": ".sqlx/query-eb142b33bd6d0d9f3ceb597be9251eac710a463d1052ba10c41b207dbf63efe1.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT workspace_id\\n      FROM af_workspace_namespace\\n      WHERE namespace = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"eb142b33bd6d0d9f3ceb597be9251eac710a463d1052ba10c41b207dbf63efe1\"\n}\n"
  },
  {
    "path": ".sqlx/query-ed9bce7f35c4dd8d41427bc56db67adf175044a8d31149b3745ceb8f9b3c82fa.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT oid, snapshot, snapshot_version, created_at\\n    FROM af_snapshot_meta\\n    WHERE oid = $1 AND partition_key = $2\\n    ORDER BY created_at DESC\",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"oid\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"snapshot\",\n        \"type_info\": \"Bytea\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"snapshot_version\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"created_at\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"ed9bce7f35c4dd8d41427bc56db67adf175044a8d31149b3745ceb8f9b3c82fa\"\n}\n"
  },
  {
    "path": ".sqlx/query-ef947984b00fdd32271e7e76d8b5d035cd4ca211b600787fda18d62a34b4c04b.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT EXISTS(\\n      SELECT 1\\n      FROM public.af_workspace_member\\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\\n      WHERE af_workspace_member.workspace_id = $1\\n      AND LOWER(af_user.email) = LOWER($2)\\n    ) AS \\\"exists\\\";\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"ef947984b00fdd32271e7e76d8b5d035cd4ca211b600787fda18d62a34b4c04b\"\n}\n"
  },
  {
    "path": ".sqlx/query-f05042dd22f862603e63f63d47b93e579545c79cabe15d32304a47ca7665a55f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT p.id, p.name, p.access_level, p.description FROM af_permissions p\\n    JOIN af_role_permissions rp ON p.id = rp.permission_id\\n    WHERE rp.role_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"id\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"name\",\n        \"type_info\": \"Varchar\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"access_level\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"description\",\n        \"type_info\": \"Text\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Int4\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      true\n    ]\n  },\n  \"hash\": \"f05042dd22f862603e63f63d47b93e579545c79cabe15d32304a47ca7665a55f\"\n}\n"
  },
  {
    "path": ".sqlx/query-f18d6e075a522b0ce5935351dd57ab0dda4d8b4ed3881c2ad0bc09c07c43e6fe.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\\n      VALUES ($1, $2, $3, $4, $5)\\n      RETURNING sid AS snapshot_id, oid AS object_id, created_at\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"snapshot_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"object_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Bytea\",\n        \"Int4\",\n        \"Int4\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f18d6e075a522b0ce5935351dd57ab0dda4d8b4ed3881c2ad0bc09c07c43e6fe\"\n}\n"
  },
  {
    "path": ".sqlx/query-f409626142553d4496d15b5dfa7da8a5a238da86f56c930c09a261f2efa1f55c.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    SELECT sid as \\\"snapshot_id\\\", oid as \\\"object_id\\\", created_at\\n    FROM af_collab_snapshot\\n    WHERE oid = $1 AND deleted_at IS NULL\\n    ORDER BY created_at DESC;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"snapshot_id\",\n        \"type_info\": \"Int8\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"object_id\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f409626142553d4496d15b5dfa7da8a5a238da86f56c930c09a261f2efa1f55c\"\n}\n"
  },
  {
    "path": ".sqlx/query-f54ced785b4fdd22c9236b566996d5d9d4a8c91902e4029fe8f8f30f3af39b39.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n    INSERT INTO af_template_view_template_category (view_id, category_id)\\n    SELECT $1 as view_id, category_id FROM\\n    UNNEST($2::uuid[]) AS category_id\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"UuidArray\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f54ced785b4fdd22c9236b566996d5d9d4a8c91902e4029fe8f8f30f3af39b39\"\n}\n"
  },
  {
    "path": ".sqlx/query-f58a2f05efbda0698d27d83be5c6816fc46e3de33f926c6343bcbfa90a387b07.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      DELETE FROM public.af_workspace\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"f58a2f05efbda0698d27d83be5c6816fc46e3de33f926c6343bcbfa90a387b07\"\n}\n"
  },
  {
    "path": ".sqlx/query-f68cc2042d6aa78feeb33640e9ef13f46c5e10ee269ea0bd965b0e57dee6cf94.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT c.workspace_id, c.oid, c.partition_key\\n        FROM af_collab c\\n        JOIN af_workspace w ON c.workspace_id = w.workspace_id\\n        WHERE c.workspace_id = $1\\n        AND NOT COALESCE(w.settings['disable_search_indexing']::boolean, false)\\n        AND c.indexed_at IS NULL\\n        ORDER BY c.updated_at DESC\\n        LIMIT $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"oid\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"partition_key\",\n        \"type_info\": \"Int4\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"f68cc2042d6aa78feeb33640e9ef13f46c5e10ee269ea0bd965b0e57dee6cf94\"\n}\n"
  },
  {
    "path": ".sqlx/query-f78c2c56568dcee0b93e759ee517fb87d6d115a02856a756d481ea4c863c0327.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT snapshot_id, oid, doc_state, doc_state_version, deps_snapshot_id, created_at\\n        FROM af_snapshot_state\\n        WHERE oid = $1 AND partition_key = $2 AND created_at >= $3\\n        ORDER BY created_at ASC\\n        LIMIT 1\\n        \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"snapshot_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"oid\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"doc_state\",\n        \"type_info\": \"Bytea\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"doc_state_version\",\n        \"type_info\": \"Int4\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"deps_snapshot_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"created_at\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Int4\",\n        \"Int8\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      false,\n      false,\n      true,\n      false\n    ]\n  },\n  \"hash\": \"f78c2c56568dcee0b93e759ee517fb87d6d115a02856a756d481ea4c863c0327\"\n}\n"
  },
  {
    "path": ".sqlx/query-f9c28d0fa124ef543259c6869d7c517deabda3af9a67c6e59d8e15c0245c83a0.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT default_published_view_id\\n      FROM af_workspace\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"default_published_view_id\",\n        \"type_info\": \"Uuid\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      true\n    ]\n  },\n  \"hash\": \"f9c28d0fa124ef543259c6869d7c517deabda3af9a67c6e59d8e15c0245c83a0\"\n}\n"
  },
  {
    "path": ".sqlx/query-fa92aff963d9a0c69fb203f76f54728c67d52a68eada59ba3bd445c4b8aeceef.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT EXISTS(\\n        SELECT 1\\n        FROM af_workspace_invitation\\n        WHERE id = $1 AND LOWER(invitee_email) = (SELECT LOWER(email) FROM af_user WHERE uuid = $2)\\n      )\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"exists\",\n        \"type_info\": \"Bool\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      null\n    ]\n  },\n  \"hash\": \"fa92aff963d9a0c69fb203f76f54728c67d52a68eada59ba3bd445c4b8aeceef\"\n}\n"
  },
  {
    "path": ".sqlx/query-faf37892741717680e9a8d8e7d8decaba571d0dd129b57334aad7c63e2a2ef59.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT\\n        i.id AS invite_id,\\n        i.workspace_id,\\n        w.workspace_name,\\n        u_inviter.email AS inviter_email,\\n        u_inviter.name AS inviter_name,\\n        i.status,\\n        i.updated_at,\\n        u_inviter.metadata->>'icon_url' AS inviter_icon,\\n        w.icon AS workspace_icon,\\n        (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\\n      FROM\\n        public.af_workspace_invitation i\\n        JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\\n        JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\\n        JOIN public.af_user u_invitee ON u_invitee.uuid = $1\\n      WHERE\\n        LOWER(i.invitee_email) = LOWER(u_invitee.email)\\n        AND i.id = $2;\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"invite_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"workspace_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"inviter_email\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"inviter_name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"status\",\n        \"type_info\": \"Int2\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"updated_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 7,\n        \"name\": \"inviter_icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 8,\n        \"name\": \"workspace_icon\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 9,\n        \"name\": \"member_count\",\n        \"type_info\": \"Int8\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\",\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false,\n      null,\n      false,\n      null\n    ]\n  },\n  \"hash\": \"faf37892741717680e9a8d8e7d8decaba571d0dd129b57334aad7c63e2a2ef59\"\n}\n"
  },
  {
    "path": ".sqlx/query-fb21df2827de97055cdc1c493b079b29667f75b18169c909c4c8341697fd0105.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n        SELECT *\\n        FROM af_chat\\n        WHERE chat_id = $1 AND deleted_at IS NULL\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"chat_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 1,\n        \"name\": \"created_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 2,\n        \"name\": \"deleted_at\",\n        \"type_info\": \"Timestamptz\"\n      },\n      {\n        \"ordinal\": 3,\n        \"name\": \"name\",\n        \"type_info\": \"Text\"\n      },\n      {\n        \"ordinal\": 4,\n        \"name\": \"rag_ids\",\n        \"type_info\": \"Jsonb\"\n      },\n      {\n        \"ordinal\": 5,\n        \"name\": \"workspace_id\",\n        \"type_info\": \"Uuid\"\n      },\n      {\n        \"ordinal\": 6,\n        \"name\": \"meta_data\",\n        \"type_info\": \"Jsonb\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": [\n      false,\n      false,\n      true,\n      false,\n      false,\n      false,\n      false\n    ]\n  },\n  \"hash\": \"fb21df2827de97055cdc1c493b079b29667f75b18169c909c4c8341697fd0105\"\n}\n"
  },
  {
    "path": ".sqlx/query-fd2a37dd917717a9bb5e1db84f03f0e84e32d2fd081955389561c6567896ea9f.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      SELECT blob\\n      FROM af_published_collab\\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\\n        AND unpublished_at IS NULL\\n        AND publish_name = $2\\n    \",\n  \"describe\": {\n    \"columns\": [\n      {\n        \"ordinal\": 0,\n        \"name\": \"blob\",\n        \"type_info\": \"Bytea\"\n      }\n    ],\n    \"parameters\": {\n      \"Left\": [\n        \"Text\",\n        \"Text\"\n      ]\n    },\n    \"nullable\": [\n      false\n    ]\n  },\n  \"hash\": \"fd2a37dd917717a9bb5e1db84f03f0e84e32d2fd081955389561c6567896ea9f\"\n}\n"
  },
  {
    "path": ".sqlx/query-fffe6f01abf0e5d8649a49b5793ccb92a9f823f07c363341357ea74bf4f4a16d.json",
    "content": "{\n  \"db_name\": \"PostgreSQL\",\n  \"query\": \"\\n      UPDATE af_workspace\\n      SET default_published_view_id = NULL\\n      WHERE workspace_id = $1\\n    \",\n  \"describe\": {\n    \"columns\": [],\n    \"parameters\": {\n      \"Left\": [\n        \"Uuid\"\n      ]\n    },\n    \"nullable\": []\n  },\n  \"hash\": \"fffe6f01abf0e5d8649a49b5793ccb92a9f823f07c363341357ea74bf4f4a16d\"\n}\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"appflowy-cloud\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nactix.workspace = true\nactix-web.workspace = true\nactix-http = { workspace = true, default-features = false, features = [\n  \"openssl\",\n  \"compress-brotli\",\n  \"compress-gzip\",\n] }\nactix-rt = \"2.9.0\"\nactix-web-actors = { version = \"4.3\" }\nactix-service = \"2.0.2\"\nactix-identity = \"0.6.0\"\nactix-session = { version = \"0.8\", features = [\"redis-rs-tls-session\"] }\nactix-multipart = { version = \"0.7.2\", features = [\"derive\"] }\nzstd.workspace = true\n\n# serde\nserde_json.workspace = true\nserde_repr.workspace = true\nserde.workspace = true\n\ntokio = { workspace = true, features = [\n  \"macros\",\n  \"rt-multi-thread\",\n  \"sync\",\n  \"fs\",\n  \"time\",\n  \"full\",\n] }\ntokio-stream.workspace = true\ntokio-util = { version = \"0.7.10\", features = [\"io\"] }\nfutures-util = { workspace = true, features = [\"std\", \"io\"] }\nchrono.workspace = true\nsecrecy.workspace = true\nrand = { version = \"0.8\", features = [\"std_rng\"] }\nanyhow.workspace = true\nreqwest = { workspace = true, features = [\n  \"json\",\n  \"rustls-tls\",\n  \"cookies\",\n  \"stream\",\n] }\nunicode-segmentation = \"1.10\"\nlazy_static.workspace = true\nfancy-regex = \"0.11.0\"\nbytes.workspace = true\nvalidator.workspace = true\nmime = \"0.3.17\"\naws-sdk-s3 = { version = \"1.88.0\", features = [\n  \"behavior-version-latest\",\n  \"rt-tokio\",\n] }\nredis = { workspace = true, features = [\n  \"json\",\n  \"tokio-comp\",\n  \"connection-manager\",\n  \"aio\",\n  \"bytes\",\n  \"uuid\",\n] }\ntracing = { version = \"0.1.40\", features = [\"log\"] }\ntracing-subscriber = { version = \"0.3.19\", features = [\n  \"registry\",\n  \"env-filter\",\n  \"ansi\",\n  \"json\",\n  \"tracing-log\",\n] }\nsqlx = { workspace = true, default-features = false, features = [\n  \"runtime-tokio-rustls\",\n  \"macros\",\n  \"postgres\",\n  \"uuid\",\n  \"chrono\",\n  \"migrate\",\n] }\nasync-trait.workspace = true\nprometheus-client.workspace = true\nitertools = \"0.11\"\nuuid.workspace = true\ntokio-tungstenite = { version = \"0.26.1\", features = [\"native-tls\"] }\ndotenvy.workspace = true\nbrotli.workspace = true\ndashmap.workspace = true\nasync-stream.workspace = true\nfutures.workspace = true\nsemver = \"1.0.22\"\ntonic.workspace = true\nprost.workspace = true\ntonic-proto.workspace = true\nappflowy-collaborate = { path = \"services/appflowy-collaborate\" }\n\n# ai\nappflowy-ai-client = { workspace = true, features = [\"dto\", \"client-api\"] }\n\ncollab = { workspace = true, features = [\"lock_timeout\"] }\ncollab-document = { workspace = true }\ncollab-entity = { workspace = true }\ncollab-folder = { workspace = true }\ncollab-user = { workspace = true }\ncollab-database = { workspace = true }\ncollab-importer = { workspace = true }\ncollab-rt-protocol.workspace = true\n\n#Local crate\nsnowflake = { path = \"libs/snowflake\" }\ndatabase.workspace = true\ndatabase-entity.workspace = true\ngotrue = { path = \"libs/gotrue\" }\ngotrue-entity = { path = \"libs/gotrue-entity\" }\ninfra = { path = \"libs/infra\" }\naccess-control.workspace = true\napp-error = { workspace = true, features = [\n  \"sqlx_error\",\n  \"actix_web_error\",\n  \"tokio_error\",\n  \"appflowy_ai_error\",\n] }\nshared-entity = { path = \"libs/shared-entity\", features = [\"cloud\"] }\nworkspace-template = { workspace = true }\ncollab-rt-entity.workspace = true\ncollab-stream.workspace = true\nyrs.workspace = true\n\npin-project.workspace = true\nbyteorder = \"1.5.0\"\nsha2 = \"0.10.8\"\nrayon.workspace = true\nmailer.workspace = true\nasync_zip.workspace = true\nsanitize-filename.workspace = true\nfutures-lite = \"2.3.0\"\nconsole-subscriber = { version = \"0.4.1\", optional = true }\nbase64.workspace = true\nmd5.workspace = true\nnanoid = \"0.4.0\"\nindexer.workspace = true\nllm-client.workspace = true\nasync-openai.workspace = true\nappflowy-proto.workspace = true\nactix-cors = { version = \"0.7.0\", optional = true }\n\n[dev-dependencies]\nflate2 = \"1.0\"\nassert-json-diff = \"2.0.2\"\nclient-api-test = { path = \"libs/client-api-test\", features = [] }\nclient-api = { path = \"libs/client-api\", features = [\n  \"test_util\",\n  \"sync_verbose_log\",\n  \"test_fast_sync\",\n  \"enable_brotli\",\n] }\ncollab-rt-entity = { path = \"libs/collab-rt-entity\" }\nhex = \"0.4.3\"\nasync-openai.workspace = true\n\n[[bin]]\nname = \"appflowy_cloud\"\npath = \"src/main.rs\"\n\n[lib]\npath = \"src/lib.rs\"\n\n#[[bench]]\n#name = \"access_control_benchmark\"\n#harness = false\n\n[workspace]\nmembers = [\n  # libs\n  \"libs/snowflake\",\n  \"libs/collab-rt-entity\",\n  \"libs/database\",\n  \"libs/database-entity\",\n  \"libs/client-api\",\n  \"libs/infra\",\n  \"libs/shared-entity\",\n  \"libs/gotrue\",\n  \"libs/gotrue-entity\",\n  \"admin_frontend\",\n  \"libs/app-error\",\n  \"libs/workspace-template\",\n  \"libs/access-control\",\n  \"libs/collab-rt-protocol\",\n  \"libs/collab-stream\",\n  \"libs/client-websocket\",\n  \"libs/client-api-test\",\n  \"libs/appflowy-ai-client\",\n  \"libs/client-api-entity\",\n  # services\n  \"services/appflowy-collaborate\",\n  \"services/appflowy-worker\",\n  # xtask\n  \"xtask\",\n  \"libs/tonic-proto\",\n  \"libs/mailer\",\n  \"libs/indexer\",\n  \"libs/llm-client\",\n  \"libs/appflowy-proto\",\n]\n\n[workspace.dependencies]\nindexer = { path = \"libs/indexer\" }\ncollab-rt-entity = { path = \"libs/collab-rt-entity\" }\ncollab-rt-protocol = { path = \"libs/collab-rt-protocol\" }\ndatabase = { path = \"libs/database\" }\ndatabase-entity = { path = \"libs/database-entity\" }\nshared-entity = { path = \"libs/shared-entity\" }\ngotrue-entity = { path = \"libs/gotrue-entity\" }\naccess-control = { path = \"libs/access-control\" }\nllm-client = { path = \"libs/llm-client\" }\nappflowy-proto = { path = \"libs/appflowy-proto\" }\nmailer = { path = \"libs/mailer\" }\napp-error = { path = \"libs/app-error\" }\nasync-trait = \"0.1.77\"\nprometheus-client = \"0.22.0\"\nbrotli = \"3.4.0\"\ncollab-stream = { path = \"libs/collab-stream\" }\ndotenvy = \"0.15.7\"\nserde_json = \"1.0.111\"\nserde_repr = \"0.1.18\"\nserde = { version = \"1.0\", features = [\"derive\"] }\nbytes = \"1.9.0\"\nworkspace-template = { path = \"libs/workspace-template\" }\nuuid = { version = \"1.6.1\", features = [\"v4\", \"v5\"] }\nanyhow = \"1.0.94\"\nactix = \"0.13.3\"\nactix-web = { version = \"4.5.1\", default-features = false, features = [\n  \"openssl\",\n  \"compress-brotli\",\n  \"compress-gzip\",\n] }\nactix-http = { version = \"3.6.0\", default-features = false }\ntokio = { version = \"1.36.0\", features = [\"sync\"] }\ntokio-stream = \"0.1.14\"\nrayon = \"1.10.0\"\nfutures-util = \"0.3.30\"\nbincode = \"1.3.3\"\nclient-websocket = { path = \"libs/client-websocket\" }\ninfra = { path = \"libs/infra\" }\ntracing = { version = \"0.1\", features = [\"log\"] }\ngotrue = { path = \"libs/gotrue\" }\nredis = \"0.29\"\nsqlx = { version = \"0.8.1\", default-features = false }\ndashmap = \"5.5.3\"\nfutures = \"0.3.30\"\nasync-stream = \"0.3.5\"\nreqwest = \"0.12.9\"\nlazy_static = \"1.4.0\"\ntonic = \"0.12.3\"\nprost = \"0.13.3\"\ntonic-proto = { path = \"libs/tonic-proto\" }\nappflowy-ai-client = { path = \"libs/appflowy-ai-client\", default-features = false }\npgvector = { version = \"0.4\", features = [\"sqlx\"] }\nclient-api-entity = { path = \"libs/client-api-entity\" }\nasync_zip = { version = \"0.0.17\", features = [\"full\"] }\nsanitize-filename = \"0.5.0\"\nbase64 = \"0.22\"\nmd5 = \"0.7.0\"\npin-project = \"1.1.5\"\narc-swap = { version = \"1.7\" }\nvalidator = \"0.19\"\nzstd = { version = \"0.13.2\", features = [] }\nchrono = { version = \"0.4.39\", features = [\n  \"serde\",\n  \"clock\",\n], default-features = false }\nhttp = \"0.2.12\"\ntokio-tungstenite = \"0.20\"\nsecrecy = { version = \"0.8.0\", features = [\"serde\"] }\nthiserror = \"2.0\"\n# collaboration\nyrs = { version = \"0.23.5\", features = [\"sync\"] }\ncollab = { version = \"0.2.0\" }\ncollab-entity = { version = \"0.2.0\" }\ncollab-folder = { version = \"0.2.0\" }\ncollab-document = { version = \"0.2.0\" }\ncollab-database = { version = \"0.2.0\" }\ncollab-user = { version = \"0.2.0\" }\ncollab-importer = { version = \"0.1.0\" }\nasync-openai = \"0.28.0\"\ncollab-plugins = { version = \"0.2.0\" }\nsmallvec = \"1.15.0\"\n\n[profile.release]\nlto = true\nopt-level = 3\ncodegen-units = 1\n\n[profile.profiling]\ninherits = \"release\"\ndebug = true\n\n[profile.ci]\ninherits = \"release\"\nopt-level = 2\nlto = false\n\n\n[patch.crates-io]\n# It's diffcult to resovle different version with the same crate used in AppFlowy Frontend and the Client-API crate.\n# So using patch to workaround this issue.\ncollab = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-entity = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-folder = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-document = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-user = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-database = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-importer = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\ncollab-plugins = { git = \"https://github.com/AppFlowy-IO/AppFlowy-Collab\", rev = \"e59260e524f33104b0ddcd6bb8f6218cad0f7e18\" }\n\n[features]\nhistory = []\n# Some AI test features are not available for self-hosted AppFlowy Cloud. Therefore, AI testing is disabled by default.\nai-test-enabled = [\"client-api-test/ai-test-enabled\"]\n# Enable Debugging for Tokio Runtime with Tokio Console\n# Reference: https://github.com/tokio-rs/console\n# Steps to Enable and Use Tokio Console:\n# 1. Run your application with debugging enabled:\n#      RUST_BACKTRACE=1 RUST_LOG=trace cargo run --package xtask\n# 2. Install the Tokio Console CLI (if not already installed):\n#      cargo install --locked tokio-console\n# 3. Open a new terminal and start the Tokio Console:\n#      tokio-console\ntokio-runtime-profile = [\"console-subscriber\", \"tokio/tracing\"]\nsync-v2 = [\"client-api-test/v2\"]\n# Enable CORS support for development environments (required when using dev.env configuration)\n# This allows frontend applications to access the backend during local development\nuse_actix_cors = [\"actix-cors\"]\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n# Using cargo-chef to manage Rust build cache effectively\nFROM lukemathwalker/cargo-chef:latest-rust-1.86 as chef\n\nWORKDIR /app\nRUN apt update && apt install lld clang -y\n\nFROM chef as planner\nCOPY . .\n# Compute a lock-like file for our project\nRUN cargo chef prepare --recipe-path recipe.json\n\nFROM chef as builder\n\n# Update package lists and install protobuf-compiler along with other build dependencies\nRUN apt update && apt install -y protobuf-compiler lld clang\n\n# Specify a default value for FEATURES; it could be an empty string if no features are enabled by default\nARG FEATURES=\"\"\nARG PROFILE=\"release\"\n\nCOPY --from=planner /app/recipe.json recipe.json\nENV CARGO_BUILD_JOBS=4\nENV CARGO_NET_GIT_FETCH_WITH_CLI=true\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\n# Reduce memory usage during compilation\nRUN echo \"Building appflowy cloud with profile: ${PROFILE}\"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      cargo chef cook --release --recipe-path recipe.json; \\\n    else \\\n      cargo chef cook --recipe-path recipe.json; \\\n    fi\n\nCOPY . .\nENV SQLX_OFFLINE true\n\n# Build the project\nRUN echo \"Building with profile: ${PROFILE}, features: ${FEATURES}, \"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      cargo build --release --features \"${FEATURES}\" --bin appflowy_cloud; \\\n    else \\\n      cargo build --features \"${FEATURES}\" --bin appflowy_cloud; \\\n    fi\n\nFROM debian:bookworm-slim AS runtime\nWORKDIR /app\nRUN apt-get update -y \\\n  && apt-get install -y --no-install-recommends openssl ca-certificates curl \\\n  && update-ca-certificates \\\n  # Clean up\n  && apt-get autoremove -y \\\n  && apt-get clean -y \\\n  && rm -rf /var/lib/apt/lists/*\n\n# Copy the binary from the appropriate target directory\nARG PROFILE=\"release\"\nRUN echo \"Building with profile: ${PROFILE}\"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      echo \"Using release binary\"; \\\n    else \\\n      echo \"Using debug binary\"; \\\n    fi\nCOPY --from=builder /app/target/$PROFILE/appflowy_cloud /usr/local/bin/appflowy_cloud\nENV APP_ENVIRONMENT production\nENV RUST_BACKTRACE 1\n\nARG APPFLOWY_APPLICATION_PORT\nARG PORT\nENV PORT=${APPFLOWY_APPLICATION_PORT:-${PORT:-8000}}\nEXPOSE $PORT\n\nCMD [\"appflowy_cloud\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "Makefile",
    "content": "ROOT = \"./script\"\nSEMVER_VERSION=$(shell grep version Cargo.toml | awk -F\"\\\"\" '{print $$2}' | head -n 1)\n\n.PHONY: run test\n\nrun:\n\t${ROOT}/run_local_server.sh\n\ntest:\n\t${ROOT}/run_local_test.sh\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <picture>\n        <source srcset=\"assets/logos/appflowy_logo_white.svg\" media=\"(prefers-color-scheme: dark)\"/>\n        <img src=\"assets/logos/appflowy_logo_black.svg\"  width=\"500\" height=\"200\" />\n    </picture>\n</p>\n\n<h4 align=\"center\">\n    <a href=\"https://discord.gg/9Q2xaN37tV\"><img src=\"https://img.shields.io/badge/AppFlowy.IO-discord-orange\"></a>\n    <a href=\"https://opensource.org/licenses/AGPL-3.0\"><img src=\"https://img.shields.io/badge/license-AGPL-purple.svg\" alt=\"License: AGPL\"></a>\n</h4>\n\n<p align=\"center\">\n    <a href=\"https://www.appflowy.com\"><b>Website</b></a> •\n    <a href=\"https://twitter.com/appflowy\"><b>Twitter</b></a>\n</p>\n\n<p align=\"center\">⚡ The AppFlowy Cloud written with Rust 🦀</p>\n\n# AppFlowy Cloud\n\nAppFlowy Cloud is adopting an open-core model to ensure the project's long-term sustainability.\n\nAppFlowy offers two deployment options:\n- AppFlowy Managed Cloud: AWS-hosted instances fully deployed and managed by the AppFlowy team\n- AppFlowy Self-hosted Cloud: Configurable services you can deploy on your own infrastructure\n\nThe codebase behind these two setups is a closed-source fork of this open-source codebase: https://github.com/AppFlowy-IO/AppFlowy-Cloud, combined with our proprietary code. \nThe commercial fork is distributed solely under our commercial license ([link](https://github.com/AppFlowy-IO/AppFlowy-SelfHost-Commercial/blob/main/SELF_HOST_LICENSE_AGREEMENT.md)). \n\n\n**AppFlowy Self-hosted Cloud is designed for teams and enterprises that want data control and modular, configurable components tailored to their own infrastructure.** \nIt comes with a Free tier suitable for **experienced IT professionals to test out our self-hosted solution** and allows seamless upgrades to higher tiers.\n\nThe Free tier offers:\n- One user seat (per instance)\n- AppFlowy Web App (your hosted appflowy.com/app)\n- Up to 3 guest editors who can be added to your selected AppFlowy pages and collaborate with you in real time\n- Publish pages\n- Unlimited workspaces\n\nYou can find the pricing details by visiting [this page](https://appflowy.com/docs/Self-hosted-Plans-and-Pricing), or your self-hosted admin panel, or our official website ([link](https://appflowy.com/pricing)).\n\nOpen source vs Open core:\n\nAs AppFlwy Cloud is adopting an open-core model, AppFlowy Web and AppFlowy Flutter will remain open source.\nAs part of the transition, we are consolidating and reorganizing our codebases in private repositories.\nRecently, we merged all AppFlowy Web–related development from our private repository back into the public one. You can check the [commit](https://github.com/AppFlowy-IO/AppFlowy-Web/commits/main/) for reference. We will also merge Flutter code back into [this](https://github.com/AppFlowy-IO/AppFlowy) public repository at a later stage.\n\nAppFlowy Cloud will continue to follow the open-core model for business reasons.\nYou're free to use https://github.com/AppFlowy-IO/AppFlowy-Cloud governed by its license.\n\nWe also have a few other open source repos, such as\n\n- [AppFlowy Website](https://github.com/AppFlowy-IO/AppFlowy-Website) for developers who want to build their own website following our navigation structure\n- [appflowy-editor](https://github.com/AppFlowy-IO/appflowy-editor) for Flutter developers to build their own apps\n- [appflowy-board](https://github.com/AppFlowy-IO/appflowy-board) for Flutter developers to build their own apps\n\n\n\n## Table of Contents\n\n- [🚀 Deployment](#deployment)\n\n\n## 🚀Deployment\n\n- See [deployment guide](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production)\n\n"
  },
  {
    "path": "SELF_HOST_LICENSE_AGREEMENT.md",
    "content": "APPFLOWY PTE. LTD. SOFTWARE LICENSE AGREEMENT\n\nIMPORTANT: PLEASE READ THIS SOFTWARE LICENSE AGREEMENT (\"LICENSE\nAGREEMENT\") CAREFULLY BEFORE USING THE APPFLOWY SELF-HOSTED COMMERCIAL\nSOFTWARE (\"SOFTWARE\"). BY USING THE SOFTWARE, YOU (EITHER AN INDIVIDUAL\nOR A SINGLE ENTITY) AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE\nAGREEMENT. IF YOU DO NOT AGREE TO THE TERMS OF THIS LICENSE AGREEMENT,\nDO NOT USE THE SOFTWARE.\n\n1. License Grant: Subject to the terms and conditions of this License\n   Agreement, APPFLOWY PTE. LTD. (\"Licensor\") grants you a\n   non-exclusive, non-transferable license to use the Software for your\n   internal business purposes. This license is granted on a per-server\n   (per-machine) basis.\n\n2 License Restrictions: a) You shall not copy, modify, distribute, sell,\nlease, sublicense, or transfer the Software or any portion thereof. b)\nYou shall not reverse engineer, decompile, or disassemble the Software,\nexcept to the extent expressly permitted by applicable law. c) You shall\nnot remove or alter any proprietary notices or labels on the Software.\n\n3. License Key:\n\n   a)  APPFLOWY PTE. LTD. will provide you with a unique license key\n   (\"License Key\") to activate the Software on a specific server\n   (machine).\n   b)  You agree that the License Key provided to you by APPFLOWY PTE.\n   LTD. will be used exclusively on the designated server (machine)\n   and will not be shared, transferred, or used on any other server\n   (machine) without explicit written permission from APPFLOWY PTE.\n   LTD.\n   c)  Reselling license and custom client are not allowed.\n\n4. Term and Billing:\n\n   a)  The license term for the Software shall be one (1) year,\n   starting from the date of purchase.\n   b)  You agree to pay the annual license fee as specified by\n   Licensor. Failure to make timely payments may result in\n   suspension or termination of the license.\n\n5. Maintenance and Support:\n\n   a)  Support is offered via email.\n   b)  During the license term, Licensor may provide maintenance and\n   support services for the Software as separately agreed upon or\n   specified in a separate support agreement.\n   c)  Licensor reserves the right to modify or enhance the support\n   services at its sole discretion.\n\n6. Intellectual Property:\n\n   a)  You acknowledge that the Software and any accompanying\n   documentation are protected by intellectual property laws and\n   treaties.\n   b)  You agree not to remove or alter any copyright, trademark, or\n   other proprietary rights notices contained in or on the\n   Software.\n\n7. Disclaimer of Warranty: THE SOFTWARE IS PROVIDED \"AS IS\" WITHOUT\n   WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES, WHETHER\n   EXPRESS, IMPLIED, OR STATUTORY, INCLUDING BUT NOT LIMITED TO THE\n   IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR\n   PURPOSE, AND NON-INFRINGEMENT.\n\n8. Limitation of Liability: IN NO EVENT SHALL LICENSOR BE LIABLE FOR\n   ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, CONSEQUENTIAL, OR\n   PUNITIVE DAMAGES ARISING OUT OF OR RELATED TO THIS LICENSE AGREEMENT\n   OR THE USE OF THE SOFTWARE, EVEN IF LICENSOR HAS BEEN ADVISED OF THE\n   POSSIBILITY OF SUCH DAMAGES.\n\n9. Termination: This License Agreement is effective until terminated.\n   Licensor may terminate this License Agreement immediately upon\n   notice to you if you breach any of the terms and conditions. Upon\n   termination, you must cease all use of the Software and destroy all\n   copies in your possession.\n\n10. Governing Law: This License Agreement shall be governed by and\n    construed in accordance with the laws of Singapore. Any legal action\n    or proceeding arising under this License Agreement shall be brought\n    exclusively in the courts of Singapore.\n\n11. Entire Agreement: This License Agreement shall be governed by and\n    construed in accordance with the laws of Singapore. Any legal action\n    or proceeding arising under this License Agreement shall be brought\n    exclusively in the courts of Singapore.\n\n12. No Refund Policy: All fees paid under this License Agreement are\n    non-cancellable and non-refundable, except to the extent required by\n    applicable law (e.g., if the Software is not delivered, is\n    materially defective or not as described, or a mandatory statutory\n    cooling‑off right applies).\n\nIf you are an EU/UK consumer, you may have a 14‑day statutory right of\nwithdrawal for digital content. By requesting immediate delivery of the\nSoftware and/or License Key and ticking the checkbox at checkout (or\nprior to key reveal), you expressly consent to immediate performance and\nacknowledge that you lose that withdrawal right once delivery occurs.\n\nBy using the Software, you acknowledge that you have read and understood\nthis License Agreement and agree to be bound by its terms and\nconditions.\n"
  },
  {
    "path": "admin_frontend/Cargo.toml",
    "content": "[package]\nname = \"admin_frontend\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\n# local dependencies\ngotrue = { path = \"../libs/gotrue\" }\ngotrue-entity = { path = \"../libs/gotrue-entity\" }\ndatabase-entity = { path = \"../libs/database-entity\" }\nshared-entity = { path = \"../libs/shared-entity\" }\n\nanyhow.workspace = true\naxum = { version = \"0.7\", features = [\"json\"] }\ntokio = { version = \"1.36\", features = [\"rt-multi-thread\", \"macros\"] }\naskama = \"0.12\"\naxum-extra = { version = \"0.9\", features = [\"cookie\"] }\nserde.workspace = true\nserde_json.workspace = true\nredis = { version = \"0.25.2\", features = [\n  \"aio\",\n  \"tokio-comp\",\n  \"connection-manager\",\n] }\nuuid = { workspace = true, features = [\"v4\"] }\ndotenvy = \"0.15\"\nreqwest.workspace = true\ntower-service = \"0.3\"\ntower-http = { version = \"0.5\", features = [\"fs\"] }\ntower = \"0.4\"\ntracing = \"0.1\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"json\"] }\njwt = \"0.16\"\nhuman_bytes = \"0.4.3\"\nrand = \"0.8.5\"\nsha2 = \"0.10.8\"\nbase64 = \"0.22.1\"\nurlencoding = \"2.1.3\"\n"
  },
  {
    "path": "admin_frontend/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n# User should build from parent directory\n\nFROM lukemathwalker/cargo-chef:latest-rust-1.86 as chef\n\nWORKDIR /app\n\nFROM chef as builder\nARG PROFILE=\"release\"\nCOPY . .\nWORKDIR /app/admin_frontend\nRUN echo \"Building admin_frontend with profile: ${PROFILE}\"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      cargo build --release --bin admin_frontend; \\\n    else \\\n      cargo build --bin admin_frontend; \\\n    fi\n\nFROM debian AS runtime\nWORKDIR /app\nRUN apt-get update -y \\\n  && apt-get install -y --no-install-recommends libc6 libssl-dev \\\n  # Clean up\n  && apt-get autoremove -y \\\n  && apt-get clean -y \\\n  && rm -rf /var/lib/apt/lists/*\n\n# Copy the binary from the appropriate target directory\nARG PROFILE=\"release\"\nRUN echo \"Using admin_frontend with profile: ${PROFILE}\"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      echo \"Using release admin_frontend binary\"; \\\n    else \\\n      echo \"Using debug admin_frontend binary\"; \\\n    fi\nCOPY --from=builder /app/target/$PROFILE/admin_frontend /usr/local/bin/admin_frontend\nCOPY --from=builder /app/admin_frontend/assets /app/assets\nENV RUST_BACKTRACE 1\nENV RUST_LOG info\n\nARG ADMIN_FRONTEND_PORT\nARG PORT\nENV PORT=${ADMIN_FRONTEND_PORT:-${PORT:-3000}}\nEXPOSE $PORT\n\nCMD [\"admin_frontend\"]\n"
  },
  {
    "path": "admin_frontend/README.md",
    "content": "# Admin Frontend\n\n## Partial Local Environment\n\n- Go to source root folder of `AppFlowy-Cloud`\n- Start running locally dependency servers: `docker compose --file docker-compose-dev.yml up -d`\n- Start SQLX migrations `cargo sqlx database create && cargo sqlx migrate run && cargo sqlx prepare --workspace`\n- Start AppFlowy-Cloud Server `cargo run`\n- Go back to `AppFlowy-Cloud/admin_frontend` directory\n- Run `cargo watch -x run -w .`, this watch for source changes, rebuild and rerun the app.\n\n## Full Local Integration Environment\n\n- Start the whole stack: `docker compose up -d`\n- Go to [web server](localhost)\n- After editing source files, do `docker compose up -d --no-deps --build admin_frontend`\n- You might need to add `--force-recreate` flag for non build changes to take effect\n\n"
  },
  {
    "path": "admin_frontend/assets/README.md",
    "content": "# Assets\n"
  },
  {
    "path": "admin_frontend/assets/apple/logo.html",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 814 1000\" height=\"48\" width=\"auto\">\n  <path d=\"M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z\"/>\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/base.css",
    "content": ":root {\n  --af-background: #1a202c;\n  --line-divider: #384967;\n  --af-purple: #9327ff;\n  --af-dark-purple: #8427e0;\n  --af-red: #fb006d;\n  --af-dark-red: #e3006d;\n  --af-cyan: #00c8ff;\n  --af-dark-cyan: #00b5ff;\n  --af-yellow: #ffce00;\n  --af-dark-yellow: #ffbd00;\n  --af-darker-yellow: #f7931e;\n}\n\nbody {\n  font-family: sans-serif;\n  background-color: var(--af-background);\n  color: #e2e9f2;\n}\n\n.button {\n  border: none;\n  border-radius: 4px;\n  margin: 4px;\n  padding: 4px 8px;\n  font-size: 16px;\n  cursor: pointer;\n}\n\n.table {\n    border-collapse: collapse; /* Collapse borders so there's no double borders */\n}\n\n.table th, .table td {\n  padding: 8px;\n}\n\n.yellow-table th {\n  border: 1px solid var(--af-dark-yellow);\n  background-color: var(--af-yellow);\n}\n\n.yellow-table td {\n  border: 1px solid var(--af-dark-yellow);\n}\n\n.red-table th {\n  border: 1px solid var(--af-dark-red);\n  background-color: var(--af-red);\n}\n\n.red-table td {\n  border: 1px solid var(--af-dark-red);\n}\n\n.cyan-table th {\n  border: 1px solid var(--af-dark-cyan);\n  background-color: var(--af-cyan);\n}\n\n.cyan-table td {\n  border: 1px solid var(--af-dark-cyan);\n}\n\n.purple-table th {\n  border: 1px solid var(--af-dark-purple);\n  background-color: var(--af-purple);\n}\n\n.purple-table td {\n  border: 1px solid var(--af-dark-purple);\n}\n\n.purple:hover {\n  background-color: var(--af-dark-purple);\n}\n\n.purple {\n  background-color: var(--af-dark-purple);\n}\n\n.purple:hover {\n  background-color: var(--af-purple);\n}\n\n.red {\n  background-color: var(--af-dark-red);\n}\n\n.red:hover {\n  background-color: var(--af-red);\n}\n\n.cyan {\n  background-color: var(--af-dark-cyan);\n}\n\n.cyan:hover {\n  background-color: var(--af-cyan);\n}\n\n.yellow {\n  background-color: var(--af-darker-yellow);\n}\n\n.yellow:hover {\n  background-color: var(--af-yellow);\n}\n\n.input {\n  box-sizing: border-box;\n  border-radius: 4px;\n  border: 1px solid var(--line-divider);\n  margin: 4px 0;\n  padding: 4px 8px;\n  font-size: 16px;\n  color: inherit;\n  background-color: inherit;\n  outline: none;\n}\n\n.input:hover {\n  border-color: var(--af-dark-cyan);\n}\n\n.input:focus {\n  border-color: var(--af-cyan);\n}\n\n.loading-button {\n  filter: brightness(0.7);\n}\n\n.oauth-item-inner {\n  border: 1px solid;\n  border-color: var(--line-divider);\n  width: 100%;\n  margin: 4px;\n  border-radius: 8px;\n}\n\n.oauth-item-inner:hover {\n  border-color: var(--af-dark-cyan);\n}\n\n.divider {\n  border: none;\n  border-top: 1px solid var(--line-divider);\n}\n"
  },
  {
    "path": "admin_frontend/assets/discord/README.md",
    "content": "# Discord\n\n- Assets are derived from: https://discord.com/branding\n"
  },
  {
    "path": "admin_frontend/assets/discord/logo.html",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 127.14 96.36\" height=\"40\" width=\"auto\">\n  <path\n    fill=\"#5865f2\"\n    d=\"M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z\"\n  />\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/github/README.md",
    "content": "# Github\n\n- Assets derived from: https://github.com/logos\n"
  },
  {
    "path": "admin_frontend/assets/github/logo.html",
    "content": "<svg\n  viewBox=\"0 0 98 96\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  preserveAspectRatio=\"xMidYMid meet\"\n  height=\"48\"\n  width=\"auto\"\n>\n  <path\n    fill-rule=\"evenodd\"\n    clip-rule=\"evenodd\"\n    d=\"M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z\"\n    fill=\"#24292f\"\n  />\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/google/README.md",
    "content": "# Google OAuth Sign Logo\n\nAssets in this directory are generated from: https://developers.google.com/identity/branding-guidelines\n"
  },
  {
    "path": "admin_frontend/assets/google/logo.css",
    "content": ".gsi-material-button {\n  -moz-user-select: none;\n  -webkit-user-select: none;\n  -ms-user-select: none;\n  -webkit-appearance: none;\n  background-color: #f2f2f2;\n  background-image: none;\n  border: none;\n  -webkit-border-radius: 20px;\n  border-radius: 20px;\n  -webkit-box-sizing: border-box;\n  box-sizing: border-box;\n  color: #1f1f1f;\n  cursor: pointer;\n  font-family: \"Roboto\", arial, sans-serif;\n  font-size: 14px;\n  height: 40px;\n  letter-spacing: 0.25px;\n  outline: none;\n  overflow: hidden;\n  padding: 0;\n  position: relative;\n  text-align: center;\n  -webkit-transition:\n    background-color 0.218s,\n    border-color 0.218s,\n    box-shadow 0.218s;\n  transition:\n    background-color 0.218s,\n    border-color 0.218s,\n    box-shadow 0.218s;\n  vertical-align: middle;\n  white-space: nowrap;\n  width: 40px;\n  max-width: 400px;\n  min-width: min-content;\n}\n\n.gsi-material-button .gsi-material-button-icon {\n  height: 20px;\n  margin-right: 12px;\n  min-width: 20px;\n  width: 20px;\n  margin: 0;\n  padding: 10px;\n}\n\n.gsi-material-button .gsi-material-button-content-wrapper {\n  -webkit-align-items: center;\n  align-items: center;\n  display: flex;\n  -webkit-flex-direction: row;\n  flex-direction: row;\n  -webkit-flex-wrap: nowrap;\n  flex-wrap: nowrap;\n  height: 100%;\n  justify-content: space-between;\n  position: relative;\n  width: 100%;\n}\n\n.gsi-material-button .gsi-material-button-contents {\n  -webkit-flex-grow: 1;\n  flex-grow: 1;\n  font-family: \"Roboto\", arial, sans-serif;\n  font-weight: 500;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  vertical-align: top;\n}\n\n.gsi-material-button .gsi-material-button-state {\n  -webkit-transition: opacity 0.218s;\n  transition: opacity 0.218s;\n  bottom: 0;\n  left: 0;\n  opacity: 0;\n  position: absolute;\n  right: 0;\n  top: 0;\n}\n\n.gsi-material-button:disabled {\n  cursor: default;\n  background-color: #ffffff61;\n}\n\n.gsi-material-button:disabled .gsi-material-button-state {\n  background-color: #1f1f1f1f;\n}\n\n.gsi-material-button:disabled .gsi-material-button-contents {\n  opacity: 38%;\n}\n\n.gsi-material-button:disabled .gsi-material-button-icon {\n  opacity: 38%;\n}\n\n.gsi-material-button:not(:disabled):active .gsi-material-button-state,\n.gsi-material-button:not(:disabled):focus .gsi-material-button-state {\n  background-color: #001d35;\n  opacity: 12%;\n}\n\n.gsi-material-button:not(:disabled):hover {\n  -webkit-box-shadow:\n    0 1px 2px 0 rgba(60, 64, 67, 0.3),\n    0 1px 3px 1px rgba(60, 64, 67, 0.15);\n  box-shadow:\n    0 1px 2px 0 rgba(60, 64, 67, 0.3),\n    0 1px 3px 1px rgba(60, 64, 67, 0.15);\n}\n\n.gsi-material-button:not(:disabled):hover .gsi-material-button-state {\n  background-color: #001d35;\n  opacity: 8%;\n}\n"
  },
  {
    "path": "admin_frontend/assets/google/logo.html",
    "content": "<svg\n  version=\"1.1\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  viewBox=\"0 0 48 48\"\n  xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n>\n  <path\n    fill=\"#EA4335\"\n    d=\"M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z\"\n  ></path>\n  <path\n    fill=\"#4285F4\"\n    d=\"M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z\"\n  ></path>\n  <path\n    fill=\"#FBBC05\"\n    d=\"M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z\"\n  ></path>\n  <path\n    fill=\"#34A853\"\n    d=\"M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z\"\n  ></path>\n  <path fill=\"none\" d=\"M0 0h48v48H0z\"></path>\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/home.css",
    "content": "#home {\n  display: flex;\n}\n\n#sidebar-content {\n  width: 100%;\n}\n"
  },
  {
    "path": "admin_frontend/assets/login.css",
    "content": "#login-parent {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  flex-direction: column;\n}\n\n#login-splash {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-direction: column;\n}\n\n#login-signin {\n  min-width: 256px;\n  max-width: 512px;\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n}\n\n#oauth-container {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  flex-wrap: wrap;\n}\n\n.oauth-icon {\n  margin: 12px;\n  width: 48px;\n}\n"
  },
  {
    "path": "admin_frontend/assets/logo.html",
    "content": "<svg\n  width=\"40px\"\n  height=\"40px\"\n  viewBox=\"0 0 40 40\"\n  fill=\"none\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n>\n  <g>\n    <path\n      d=\"M39.3893 24.0859C38.2579 30.2036 33.3209 35.5498 27.6075 38.5519C26.8927 38.9266 26.6068 39.141 25.8008 39.1928H37.3977C37.6588 39.201 37.9189 39.1568 38.1625 39.0627C38.4062 38.9686 38.6285 38.8265 38.8163 38.645C39.0041 38.4634 39.1535 38.246 39.2558 38.0056C39.358 37.7652 39.4109 37.5068 39.4115 37.2456V24.0859H39.3893Z\"\n      fill=\"#F7931E\"\n    ></path>\n    <path\n      d=\"M26.7479 38.8428C26.5132 38.86 26.2776 38.86 26.043 38.8428H26.7479Z\"\n      fill=\"#FFCE00\"\n    ></path>\n    <path\n      d=\"M15.1981 12.2815C15.0231 12.4245 14.8481 12.5576 14.6682 12.6833C11.7326 14.7488 2.77054 21.4481 0.742007 18.5643C-1.24709 15.7347 0.847994 7.70683 6.00684 3.84447C6.10543 3.7656 6.20402 3.69658 6.30508 3.6251C11.9076 -0.311199 16.0978 0.233524 18.1238 3.11982C20.0267 5.82618 17.8971 10.0681 15.1981 12.2815Z\"\n      fill=\"#8427E0\"\n    ></path>\n    <path\n      d=\"M37.1026 18.4809C34.3297 20.4281 29.9546 18.1555 27.7881 15.3703C27.6993 15.2569 27.6155 15.141 27.5416 15.0277C25.4785 12.0896 18.7792 3.12755 21.663 1.10148C24.5468 -0.924595 32.8261 1.2888 36.6022 6.66455C36.686 6.78287 36.7674 6.91104 36.8487 7.01702C40.5089 12.4569 39.9322 16.4967 37.1026 18.4809Z\"\n      fill=\"#00B5FF\"\n    ></path>\n    <path\n      d=\"M33.3933 36.1608C33.2947 36.2372 33.1986 36.3087 33.1025 36.3753C27.4999 40.319 23.3098 39.7644 21.2837 36.883C19.3833 34.1717 21.508 29.9372 24.207 27.7213C24.3795 27.5759 24.5594 27.4403 24.7394 27.3146C27.6749 25.2516 36.637 18.5547 38.6631 21.4361C40.6522 24.2657 38.5571 32.2985 33.3933 36.1608Z\"\n      fill=\"#FFBD00\"\n    ></path>\n    <path\n      d=\"M17.751 38.8995C14.8672 40.9231 6.5879 38.7121 2.81182 33.3364C2.73295 33.2279 2.66147 33.1195 2.58752 33.011C-1.09736 27.5687 -0.515667 23.5067 2.309 21.5201C5.07944 19.5729 9.45694 21.843 11.621 24.6307C11.7098 24.7441 11.7936 24.8574 11.8675 24.9733C13.9404 27.9113 20.6373 36.8734 17.751 38.8995Z\"\n      fill=\"#E3006D\"\n    ></path>\n    <path\n      d=\"M15.1977 12.2815C11.1923 14.0414 2.69862 17.7041 1.42185 14.7685C0.352121 12.3037 2.33383 7.25577 6.00886 3.84447C6.10745 3.7656 6.20604 3.69658 6.3071 3.6251C11.9071 -0.3112 16.0973 0.233523 18.1234 3.11982C20.0262 5.82618 17.8966 10.0681 15.1977 12.2815Z\"\n      fill=\"#9327FF\"\n    ></path>\n    <path\n      d=\"M37.1041 18.4804C34.3312 20.4276 29.9561 18.1551 27.7896 15.3698C25.9878 11.261 22.5568 3.1296 25.4283 1.88487C28.0163 0.763384 33.4364 2.99404 36.8403 7.01661C40.5104 12.4564 39.9337 16.4963 37.1041 18.4804Z\"\n      fill=\"#00C8FF\"\n    ></path>\n    <path\n      d=\"M33.3933 36.1608C33.2947 36.2372 33.1986 36.3087 33.1024 36.3753C27.4999 40.319 23.3098 39.7644 21.2837 36.883C19.3833 34.1717 21.508 29.9372 24.207 27.7213C28.2098 25.959 36.7085 22.2987 37.9852 25.2319C39.055 27.6991 37.0733 32.7471 33.3933 36.1608Z\"\n      fill=\"#FFCE00\"\n    ></path>\n    <path\n      d=\"M13.9817 38.1181C11.4035 39.2371 5.99815 37.0163 2.59179 33.011C-1.09802 27.5687 -0.516326 23.5067 2.30834 21.5201C5.07878 19.5729 9.45627 21.843 11.6204 24.6307C13.4246 28.742 16.8532 36.8709 13.9817 38.1181Z\"\n      fill=\"#FB006D\"\n    ></path>\n  </g>\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/message.css",
    "content": "@keyframes slideIn {\n  from {\n    top: -20%;\n  }\n  to {\n    top: 1%;\n  }\n}\n\n.message.slideIn {\n  animation: slideIn 0.2s forwards;\n}\n\n.message {\n  border: none;\n  border-radius: 4px;\n  padding: 8px 16px;\n  display: none;\n  position: fixed;\n  left: 50%;\n  transform: translateX(-50%);\n  color: #fff;\n  z-index: 1;\n}\n"
  },
  {
    "path": "admin_frontend/assets/minio/logo.html",
    "content": "<svg\n  height=\"2500\"\n  width=\"1238\"\n  xmlns=\"http://www.w3.org/2000/svg\"\n  xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n  viewBox=\"-0.23099999999999998 -10.007999999999985 566.439 1147.992\"\n>\n  <path\n    d=\"M359 1135.442c-1.375-.74-16.788-15.67-34.25-33.18L293 1070.428V863.38c0-113.876-.289-207.336-.641-207.69-.649-.647-52.692 25.508-205.773 103.415-46.152 23.488-84.548 42.462-85.324 42.164-.906-.347-1.206-1.359-.837-2.827.315-1.257 1.018-6.483 1.562-11.614 2.754-25.985 23.256-87.028 45.006-134 59.272-128.003 155.174-234.087 277.19-306.617 19.913-11.837 36.64-20.494 38.337-19.843 1.294.497 1.48 13.97 1.48 107.1 0 76.287.31 106.531 1.091 106.531.6 0 5.174-2.109 10.165-4.686 35.003-18.079 60.409-51.848 68.9-91.58 2.871-13.436 3.13-37.923.537-50.916-4.763-23.874-15.756-46.334-31.252-63.852-7.652-8.65-33.057-35.365-77.946-81.966-67.875-70.462-80.303-83.931-88.255-95.65-5.834-8.6-10.87-19.113-13.922-29.062-2.447-7.979-2.7-10.307-2.754-25.288-.065-18.287.974-23.978 6.985-38.248 11.6-27.537 41.822-53.223 66.951-56.903 17.627-2.58 39.898-2.083 54.386 1.217 11.81 2.69 30.942 14.225 44.057 26.562 15.396 14.484 23.147 26.045 81.04 120.872 68.315 111.898 76.461 125.692 77.628 131.443 1.155 5.692.319 8.097-2.79 8.027-2.589-.058-1.805.745-124.8-127.97-10.774-11.275-29.537-31.075-41.694-44-31.729-33.731-44.894-46.44-50.863-49.098-7.641-3.404-18.242-3.248-25.625.378-14.476 7.109-21.328 23.505-15.867 37.97 2.708 7.171-1.064 3.061 87.873 95.75 39.844 41.525 75.834 79.55 79.979 84.5 66.885 79.884 61.447 198.583-12.46 271.993-16.97 16.855-33.934 28.403-65.364 44.493-10.725 5.49-20.962 10.883-22.75 11.984l-3.25 2v257.934c0 141.863-.273 258.644-.607 259.514-.703 1.833-1.03 1.835-4.393.024zM181.37 635.057c87.548-44.936 105.617-54.442 108.183-56.912 1.61-1.548 2.312-3.78 2.792-8.857.969-10.263.782-118.091-.206-118.702-2.176-1.345-38.95 31.338-63.639 56.56-42.682 43.6-78.365 92.834-107.7 148.595-3.836 7.292-6.656 13.259-6.267 13.259s30.467-15.274 66.838-33.943z\"\n    fill=\"#f8e6ea\"\n  />\n  <path\n    d=\"M326.794 1103.247L293 1069.493V653.835l-61.75 31.216c-33.962 17.17-99.189 50.281-144.947 73.583C40.545 781.935 2.36 801 1.445 801c-1.303 0-1.517-.579-.989-2.68.37-1.475 1.064-6.088 1.543-10.25 2.044-17.787 16.212-64.457 30.15-99.315 60.285-150.768 168.623-273.685 310.8-352.622 9.047-5.023 17.211-9.133 18.143-9.133.932 0 1.967.712 2.301 1.582.334.87.607 49.05.607 107.067v105.486l2.25-.696c3.251-1.005 8.982-3.99 16.594-8.645 30.749-18.803 51.879-47.78 60.904-83.52 2.506-9.928 2.721-12.345 2.736-30.774.017-21.356-.819-27.208-6.214-43.5-3.454-10.434-12.292-27.181-19.234-36.452-8.043-10.74-25.046-28.799-112.54-119.527-27.995-29.03-50.647-53.495-55.989-60.469-24.215-31.615-28.859-67.857-12.988-101.36 5.684-12 11.29-19.777 21.124-29.305 13.369-12.953 30.91-22.657 45.24-25.028 11.674-1.93 30.737-2.165 41.782-.512 26.459 3.96 51.083 20.487 71.976 48.31 10.828 14.418 42.225 65.01 118.224 190.496 23.02 38.01 26.632 45.493 23.482 48.643-2.746 2.746 1.94 7.407-105.398-104.843-22.854-23.9-51.706-54.253-64.116-67.453-33.903-36.062-44.11-45.712-51.126-48.337-16.12-6.03-32.301.405-40.36 16.053-3.808 7.393-3.856 16.908-.126 24.996 3.153 6.84-1.582 1.721 88.156 95.288 39.562 41.25 74.83 78.375 78.375 82.5 24.432 28.436 40.814 66.001 45.79 105 2.069 16.214 1.565 43.117-1.085 58-7.484 42.029-26.615 79.088-55.992 108.465-18.265 18.266-33.294 28.417-69.965 47.258l-21 10.79-.505 259.993c-.277 142.997-.615 260.106-.75 260.244-1.21 1.234-6.985-4.073-36.45-33.503zM184.75 633.93c93.416-47.882 102.347-52.622 105.501-56.01l2.75-2.952v-62.984c0-34.641-.298-62.984-.663-62.984-.364 0-4.077 2.79-8.25 6.2-50.345 41.139-92.928 87.738-127.773 139.824-19.638 29.356-45.804 76.123-41.742 74.607 1.06-.396 32.64-16.461 70.177-35.7z\"\n    fill=\"#f5dbe0\"\n  />\n  <path\n    d=\"M343.764 1120.25c-9.338-9.213-24.58-24.707-33.87-34.433L293 1068.134V861.067C293 747.18 292.831 654 292.625 654c-.509 0-122.164 61.623-214.456 108.631C36.737 783.734 2.151 801 1.311 801c-1.137 0-1.365-.65-.89-2.544.352-1.4 1.05-5.789 1.553-9.754.503-3.965 2.7-14.183 4.884-22.706 11.226-43.825 26.245-83.415 49.126-129.496 14.488-29.177 26.946-50.942 43.503-76C154.034 477.942 228.025 406.465 314 353.275 332.07 342.095 359.314 327 361.42 327c1.43 0 1.58 10.11 1.58 106.531 0 90.157.214 106.614 1.394 107.067.766.294 4.506-1.033 8.31-2.95 31.2-15.711 55.689-43.402 67.175-75.96 5.354-15.177 7.39-27.284 7.433-44.188.069-27.684-7.106-51.819-22.147-74.5-7.924-11.95-16.394-21.143-86.619-94.02-74.473-77.287-83.268-86.882-92.217-100.612-10.866-16.669-15.624-32.513-15.578-51.868.017-7.098.67-14.906 1.597-19.115C239.14 46.56 259.374 21.475 288.308 8 301.058 2.064 311.394 0 328.376 0c24.13 0 41.09 5.323 60.409 18.959 22.314 15.75 30.57 27.083 86.718 119.045 64.439 105.541 84.8 139.73 86.022 144.44.721 2.78 1.235 5.069 1.143 5.088-.092.019-.935.62-1.871 1.335-1.481 1.13-2.38.67-6.892-3.533-7.137-6.65-127.615-132.457-165.094-172.399-33.654-35.863-38.714-40.628-47.348-44.58-15.282-6.997-33.497-.167-41.157 15.43-2.327 4.74-2.802 6.994-2.785 13.215.018 6.282.5 8.451 2.966 13.358 2.986 5.942 4.062 7.087 132.824 141.345 16.395 17.096 32.823 34.844 36.504 39.44 48.423 60.453 57.787 142.92 24.152 212.711-10.815 22.441-23.468 39.73-42.256 57.742-17.912 17.172-30.529 25.545-66.211 43.943-11.55 5.955-21.33 11.307-21.735 11.894-.404.587-.741 117.73-.75 260.317-.011 192.013-.308 259.25-1.145 259.25-.621 0-8.77-7.537-18.106-16.75zM128.207 663.112c7.311-3.789 40.293-20.736 73.293-37.66 78.452-40.235 87.67-45.14 89.728-47.756 1.595-2.027 1.73-7.052 1.75-65.446.012-35.174-.364-63.25-.847-63.25-.478 0-7.79 5.803-16.25 12.894-54.993 46.103-100.245 98.977-136.6 159.606C130.755 635.719 113 668.07 113 669.386c0 1.347 2.448.337 15.207-6.274z\"\n    fill=\"#f3d5da\"\n  />\n  <path\n    d=\"M327.705 1103.771c-18.038-18.299-33.225-34.313-33.75-35.586-.596-1.445-.955-79.755-.955-208.25C293 746.67 292.71 654 292.358 654c-.735 0-58.565 29.257-194.952 98.63C45.104 779.235 1.743 801 1.048 801c-.83 0-1.061-.812-.673-2.359.326-1.297 1.064-5.684 1.64-9.75 3.734-26.34 22.01-82.29 40.468-123.891C97.243 541.585 182.948 440.119 295.5 365.455c27.812-18.45 64.355-39.28 66.54-37.93.604.373.96 40.089.96 107.034 0 96.372.15 106.441 1.581 106.441 2.447 0 15.009-6.574 24.419-12.779 10.321-6.805 27.59-23.864 34.687-34.264 10.508-15.4 18.31-34.114 21.968-52.687 2.347-11.922 2.356-36.577.016-48.452-3.445-17.49-10.247-34.236-19.926-49.058-8.443-12.93-12.793-17.665-91.186-99.26-73.9-76.918-75.216-78.329-83.487-89.484-21.941-29.592-26.33-63.118-12.576-96.07 12.049-28.866 40.724-52.138 70.8-57.458 10.412-1.842 27.712-1.862 38.794-.045 15.939 2.614 31.828 10.283 47.52 22.935 18.996 15.317 26.112 25.64 90.386 131.122 67.058 110.052 74.839 123.215 75.52 127.757.734 4.895-.194 6.297-3.475 5.256-1.068-.339-16.945-16.23-35.283-35.315-65.288-67.944-103.645-108.167-131.339-137.726C347.756 68.867 343.766 65.677 329 65.56c-6.246-.05-8.443.403-13.142 2.71-6.27 3.08-12.522 9.278-16.02 15.88-1.844 3.482-2.307 5.946-2.32 12.35-.026 11.572 2.591 16.317 17.785 32.254 6.684 7.01 42.288 44.175 79.12 82.59 36.83 38.413 69.842 73.288 73.36 77.5 33.308 39.886 50.287 94.853 45.191 146.3-5.69 57.444-34.133 108.197-79.593 142.023-12.31 9.16-20.64 14.052-48.12 28.264L363.5 616.685l-.252 260.158c-.2 207.1-.506 260.161-1.5 260.178-.686.012-16.006-14.95-34.043-33.25zM183 635.247c91.557-46.97 105.362-54.258 107.75-56.884l2.25-2.475v-63.444c0-40.888-.35-63.444-.983-63.444-1.42 0-24.956 19.838-41.83 35.258-42.086 38.458-83.947 90.712-114.494 142.92C125.71 644.24 113 668.139 113 669.848c0 1.384-3.982 3.352 70-34.601z\"\n    fill=\"#f0cdd3\"\n  />\n  <path\n    d=\"M327.575 1103.383l-33.492-33.883-.042-207.191c-.028-139.833-.373-207.396-1.062-207.822-1.04-.643-61.599 29.727-208.669 104.645C39.106 782.16 1.613 801 .993 801c-.62 0-.882-.787-.583-1.75.3-.963 1.003-5.005 1.562-8.982C5.303 766.557 20.177 718.36 35.539 681.5 66.753 606.601 108.36 540.212 160.57 482c14.318-15.964 48.54-49.624 63.43-62.388 27.505-23.58 56.97-45.308 86-63.419 20.006-12.481 48.248-28.363 50.786-28.56 1.606-.125 1.746 6.621 2.214 106.867.497 106.304.513 106.998 2.5 106.673 2.723-.446 15.29-7.25 23.917-12.95 9.657-6.38 28.988-25.848 35.47-35.723 9.908-15.093 17.022-32.366 20.798-50.5 2.471-11.864 2.459-38.238-.023-50-4.629-21.936-15.253-44.618-28.106-60-6.183-7.4-28.242-30.713-78.538-83-77.14-80.194-82.56-86.09-91.597-99.61-6.43-9.622-10.579-18.459-13.664-29.101-1.953-6.74-2.249-10.01-2.195-24.289.056-14.977.31-17.311 2.756-25.288 9.247-30.153 29.613-52.425 59.1-64.632C302.727 2.227 315.624.036 329 .035c28.69-.001 51.55 9.78 74.178 31.737 16.636 16.142 23.899 27.168 96.08 145.859 60.505 99.49 61.547 101.257 62.272 105.547.859 5.081-.486 6.719-3.981 4.848-2.876-1.54-101.803-104.362-162.65-169.054-48.333-51.387-50.942-53.488-66.399-53.452-9.976.023-15.814 2.76-23.258 10.905-6.447 7.055-8.675 13.298-8.015 22.459.675 9.353 4.077 15.353 15.555 27.426 5.328 5.605 41.354 43.219 80.058 83.587 38.703 40.369 73.097 76.819 76.43 81 9.534 11.959 17.423 24.516 24.253 38.603 41.791 86.197 16.65 190.055-59.83 247.156-12.883 9.62-19.441 13.502-47.07 27.867L363.5 616.547 363 876.19c-.275 142.804-.822 259.966-1.216 260.36-.394.394-15.788-14.531-34.209-33.167zM187.5 633.005c95.736-49.078 101.532-52.168 103.951-55.407 2.006-2.686 2.055-4.046 2.348-65.32.176-36.676-.077-62.956-.61-63.489-1.878-1.878-34.514 26.388-60.207 52.146-37.298 37.392-67.08 75.653-95.03 122.09-13.31 22.111-26.83 48.52-24.37 47.604 1.055-.393 34.318-17.324 73.918-37.624z\"\n    fill=\"#eec3cb\"\n  />\n  <path\n    d=\"M355.743 1131.808c-3.012-2.92-18.136-18.178-33.61-33.91l-28.133-28.6v-207.65C294 677.193 293.831 654 292.491 654 291.08 654 207.984 695.853 89 756.493 19.364 791.983.899 801.24.555 800.84c-.677-.79 1.983-14.583 6.14-31.84 16.268-67.54 51.435-145.745 94.658-210.5 55.262-82.793 130.119-154.36 216.785-207.257C335.15 340.86 358.923 328 361.108 328c1.844 0 1.892 2.679 1.892 106.441 0 70.977.338 106.65 1.016 107.069.558.345 5.338-1.648 10.622-4.43 45.452-23.928 73.327-69.436 73.356-119.758.018-31.225-10.14-59.904-29.995-84.683-6.962-8.689-15.084-17.313-76.413-81.139-78.384-81.576-80.323-83.646-88.76-94.728-7.909-10.39-15-23.958-18.434-35.272-2.45-8.073-2.738-10.596-2.8-24.5-.084-18.657 1.76-26.989 9.408-42.5a98.228 98.228 0 0 1 42.722-43.69C298.73 2.99 311.32-.003 329.192.003c35.679.012 66.18 17.464 91.754 52.497 10.681 14.632 50.63 79.305 126.34 204.528 13.99 23.14 16.968 30.17 13.369 31.551-2.95 1.132 3.432 7.543-96.212-96.647-28.63-29.938-61.765-64.782-73.632-77.432C348.048 68.915 343.82 65.566 329 65.524c-10.052-.028-15.364 2.093-22.422 8.952-6.083 5.912-8.775 11.596-9.361 19.761-.569 7.921.86 13.837 4.767 19.739 1.535 2.319 25.68 28.096 53.654 57.282 27.974 29.186 63.412 66.155 78.751 82.153 15.34 16 30.55 32.343 33.8 36.321 20.368 24.923 34.469 54.678 41.357 87.268 2.191 10.368 2.732 16.038 3.172 33.245.34 13.303.1 24.244-.668 30.5-5.485 44.667-25.384 85.575-57.045 117.273-18.218 18.24-32.253 27.6-74.005 49.361l-17.5 9.121-.5 259.667c-.275 142.816-.789 259.955-1.141 260.308-.353.352-3.105-1.748-6.117-4.667zM175.767 639.326C267.569 592.224 286.143 582.45 290 579.212l3.5-2.938.272-63.315c.153-35.624-.113-63.7-.61-64.196-1.017-1.018-10.852 6.594-28.5 22.058-50.196 43.983-92.561 94.786-126.904 152.179C130.125 635.755 112 669.042 112 670.303c0 .383.458.697 1.017.697s28.797-14.253 62.75-31.674z\"\n    fill=\"#ecbbc4\"\n  />\n  <path\n    d=\"M327.654 1102.92L294 1068.72V861.528c0-113.957-.3-207.493-.665-207.859-.688-.687-62.413 30.314-208.167 104.55C38.786 781.84.678 801.01.483 800.816c-.714-.713 5.552-29.746 9.563-44.316 24.828-90.167 73.447-182.424 134.91-256 41.699-49.915 94.518-97.291 148.544-133.237 27.866-18.54 65.272-39.948 67.984-38.908 1.342.515 1.516 12.839 1.516 107.114 0 85.403.252 106.531 1.269 106.531 2.044 0 15.658-7.133 23.239-12.176 31.467-20.932 52.386-53.016 58.92-90.369 2.026-11.586 2.03-33.27.008-44.955-2.122-12.266-7.502-27.966-13.539-39.509-10.215-19.534-14.458-24.296-107.346-120.491-66.114-68.467-76.684-80.625-85.015-97.787-6.78-13.97-8.988-24.213-8.883-41.213.103-16.71 1.994-25.089 8.903-39.444 5.907-12.273 11.377-19.822 21.073-29.082C280.254 9.186 303.143.012 328.919.004c27.259-.01 51.787 10.423 73.91 31.439 14.701 13.964 25.565 30.442 91.683 139.057 68.044 111.78 69.235 113.865 67.028 117.435-1.112 1.799-2.407 1.228-7.223-3.185-6.325-5.796-128.751-133.62-166.188-173.514-32.29-34.41-39.553-41.118-47.917-44.254-16.24-6.089-33.451 1.42-41.025 17.898-5.3 11.534-2.744 23.778 7.345 35.173 2.652 2.996 38.895 40.997 80.539 84.447s77.958 81.7 80.698 85c18.501 22.285 33.364 52.496 40.274 81.864 9.164 38.943 5.706 82.895-9.475 120.441-10.544 26.077-23.176 44.957-43.995 65.756-20.138 20.119-31.854 28.091-70.326 47.853L363 616.328l-.015 259.086c-.008 142.497-.39 259.675-.846 260.395-.592.934-10.474-8.49-34.485-32.89zM226 613.76c59.775-30.79 64.292-33.293 66.334-36.75 1.507-2.55 1.666-8.83 1.666-65.915 0-50.122-.26-63.096-1.262-63.096-.694 0-8.456 6.022-17.25 13.382-56.138 46.99-102 100.937-139.393 163.968-9.25 15.593-24.095 43.665-24.095 45.564 0 1.236 24.193-10.893 114-57.153z\"\n    fill=\"#e7a8b4\"\n  />\n  <path\n    d=\"M327.75 1102.719l-33.75-34.55V860.584C294 720.568 293.669 653 292.983 653c-1.03 0-86.04 42.946-217.457 109.855-39.586 20.155-72.616 36.803-73.4 36.997-1.893.468-1.17-4.722 3.403-24.408C19.54 715.118 48.354 646.005 84.514 585.98c60.389-100.24 146.515-184.314 249.34-243.398C346.489 335.324 360.36 328 361.476 328c.289 0 .525 47.438.525 105.418 0 57.98.273 106.13.607 107 .889 2.317 2.636 1.985 10.668-2.027 37.53-18.744 63.943-53.437 72.862-95.703 3.026-14.337 3.026-37.039 0-51.376-3.879-18.381-9.76-32.729-19.599-47.812-8.571-13.14-11.725-16.575-89.537-97.5-86.574-90.037-87.36-90.934-96.49-110.209-6.44-13.593-8.812-24.382-8.75-39.791.102-25.788 9.633-48.529 28.043-66.915C278.264 10.65 299.39 1.322 325 .302c25.642-1.022 48.21 7.016 70.778 25.21 18.223 14.691 25.318 25.053 91.4 133.488 58.055 95.262 73.201 120.658 74.122 124.286 2.927 11.526-1.735 7.252-70.927-65.032C405.556 129.648 410.784 135.137 386.31 109c-29.712-31.73-35.711-37.356-43.942-41.215-13.39-6.278-26.583-3.676-36.812 7.26-6.731 7.196-9.014 12.76-9.01 21.955.005 10.941 2.944 16.327 16.832 30.855 6.122 6.405 41.73 43.557 79.127 82.56 37.398 39.003 70.852 74.328 74.343 78.5 31.023 37.076 47.716 86.1 45.825 134.585-2.094 53.694-23.857 102.177-62.021 138.165-16.715 15.761-31.872 25.645-67.151 43.786-10.45 5.373-19.45 10.193-20 10.71-.66.619-1.17 89.577-1.5 261.024l-.5 260.084zM176.782 639.314c97.896-50.22 110.467-56.844 113.968-60.053l3.25-2.978V512.14c0-55.443-.197-64.141-1.454-64.141-1.988 0-23.698 18.096-42.91 35.767-52.772 48.54-101.467 113.396-134.744 179.46-4.264 8.468-4.364 8.773-2.86 8.773.567 0 29.705-14.709 64.75-32.686z\"\n    fill=\"#e297a4\"\n  />\n  <path\n    d=\"M328.25 1102.985L295 1069.191V861.564c0-181.877-.183-207.697-1.472-208.192-1.486-.57-54.004 25.728-208.37 104.336C39.319 781.05 1.57 799.904 1.27 799.605c-1.307-1.307 7.447-38.274 14.233-60.105 27.45-88.305 75.203-174.944 135.038-245 15.999-18.731 56.4-59.19 74.958-75.065 27.799-23.78 56.058-44.564 85.5-62.887 20.619-12.832 49.008-28.66 50.145-27.959.47.291.855 48.176.855 106.411 0 71.19.336 106.09 1.024 106.515 1.888 1.166 6.683-.586 15.613-5.704 35.44-20.313 59.035-52.42 67.472-91.811 1.817-8.482 2.249-13.67 2.249-27 0-24.684-3.774-40.278-15.025-62.078-9.872-19.129-11.304-20.758-91.317-103.922-81.186-84.383-87.064-90.834-96.12-105.491-10.427-16.878-14.445-32.5-13.612-52.928.624-15.318 2.338-22.677 8.2-35.204 8.315-17.77 19.452-30.817 34.9-40.887 40.652-26.498 89.547-20.615 126.753 15.251 10.156 9.79 19.568 21.9 30.866 39.712C456.236 108.082 557.285 274.24 560.135 280.5c4.43 9.73.468 10.06-9.046.75-5.94-5.812-49.196-50.924-120.578-125.75-10.494-11-28.577-30.125-40.184-42.5-42.045-44.825-46.54-48.376-61.327-48.458-9.068-.05-16.538 2.995-22.864 9.322-12.418 12.418-13.291 30.063-2.172 43.888 2.1 2.611 22.178 23.873 44.617 47.248 22.44 23.375 57.218 59.6 77.286 80.5s39.182 41.375 42.476 45.5c22.455 28.121 36.848 60.87 42.344 96.346 2.603 16.802 2.32 45.234-.62 62.154-10.04 57.801-42.8 106.425-92.416 137.172-5.033 3.12-18.376 10.392-29.651 16.163-11.275 5.77-21.737 11.348-23.248 12.395l-2.748 1.903-.252 259.824-.252 259.822zM184.368 635.657c89.415-45.847 103.617-53.338 106.576-56.214 1.772-1.723 2.641-3.964 3.165-8.16.875-7.004 1.113-118.851.26-122.033-.33-1.238-.982-2.25-1.447-2.25-.464 0-6.15 4.343-12.633 9.651C220.538 505.57 169.98 565.29 131.59 632.3 122.65 647.909 111 670.142 111 671.6c0 1.5 7.7-2.271 73.368-35.943z\"\n    fill=\"#e08e9d\"\n  />\n  <path\n    d=\"M328.5 1103.22l-33-33.282-.5-208.205c-.45-187.265-.656-208.236-2.055-208.505-1.508-.29-61.494 29.84-214.445 107.715-40.7 20.722-74.787 37.973-75.75 38.335-2.26.851-2.236-.098.29-11.539C22.195 700.975 62.887 611.945 118.328 535.5c45.49-62.722 107.732-122.39 173.72-166.53 24.277-16.24 64.94-39.97 68.49-39.97 1.274 0 1.462 13.697 1.462 106.393 0 90.745.208 106.473 1.413 106.935 1.805.693 13.6-4.949 22.893-10.949 40.748-26.31 64.543-72.056 62.354-119.879-1.239-27.056-9.488-51.087-24.947-72.676-7.266-10.145-17.713-21.504-64.63-70.27-19.02-19.769-41.108-42.734-49.083-51.033-49.744-51.764-61.127-65.039-68.194-79.521-7.835-16.059-10.345-28.35-9.494-46.5 1.11-23.68 9.763-43.493 26.579-60.857 9.527-9.837 17.316-15.451 29.257-21.087C303.305 2.402 311.81.564 329.5.62c13.328.042 16.003.348 24 2.747 18.49 5.549 33.988 14.541 48.13 27.927 14.997 14.197 23.001 26.03 73.87 109.206 42.477 69.453 83.792 137.954 85.118 141.128 3.074 7.358.28 8.522-6.286 2.62-4.962-4.462-112.2-116.47-153.817-160.66-53.374-56.676-56.348-59.114-72.015-59.057-9.665.035-15.615 2.525-22.543 9.437-7.113 7.097-9.462 12.83-9.433 23.032.02 7.332.407 9.234 2.815 13.845 3.462 6.63.28 3.206 87.514 94.155 39.829 41.525 75.459 79.1 79.177 83.5 32.666 38.652 50.133 92.559 46.044 142.104-4.95 59.993-32.433 110.327-79.68 145.935-10.457 7.882-22.303 14.844-46.554 27.36-11.912 6.148-22.15 11.768-22.748 12.49-.777.936-1.16 75.535-1.34 260.711l-.252 259.4zM170.68 642.836c99.368-50.956 115.676-59.548 119.82-63.124l4-3.452.306-62.338c.169-34.286.048-63.37-.269-64.63-.316-1.261-1.06-2.293-1.653-2.293-1.443 0-19.35 14.755-35.384 29.158-56.004 50.305-101.01 108.237-137.896 177.498C112.061 667.82 109.872 673 111.43 673c.237 0 26.9-13.573 59.25-30.163z\"\n    fill=\"#dd8595\"\n  />\n  <path\n    d=\"M328.25 1102.718l-33.25-34.09V860.874c0-138.18-.337-207.963-1.005-208.376-.907-.56-84.986 41.709-237.495 119.396-30.25 15.41-55.145 27.603-55.322 27.097-.513-1.469 6.48-31.04 11.455-48.435C37.769 662.666 85.958 572.215 145.589 501c50.066-59.793 110.847-111.626 177.911-151.721C340.441 339.15 359.139 329 360.855 329c.853 0 1.145 27.19 1.145 106.531 0 91.54.202 106.61 1.432 107.081.788.303 5.061-1.28 9.496-3.515 38.466-19.396 64.33-52.83 73.567-95.097 2.465-11.279 3.048-35.373 1.139-47.012-4.19-25.541-14.94-48.987-31.174-67.988-6.52-7.631-30.298-32.698-74.467-78.5-86.883-90.097-92.05-95.966-101.024-114.74-5.264-11.011-8.012-22.027-8.671-34.76-.666-12.866.712-22.375 5.11-35.262 4.654-13.634 11.34-24.322 22.092-35.317 13.233-13.53 26.217-21.306 45.216-27.079C312.433.997 315.26.665 328 .609c12.722-.056 15.665.26 24 2.574 18.452 5.124 35.03 14.496 49.437 27.951C415.546 44.311 424.984 58.282 474.98 140c35.268 57.646 84.493 139.28 85.804 142.297 2.907 6.687.275 7.553-5.94 1.953-5.603-5.048-126.874-131.692-164.54-171.83-33.2-35.379-39.277-41.192-47.131-45.088-11.796-5.85-26.014-3.776-36.067 5.263-13.877 12.476-14.96 31.824-2.591 46.314 2.481 2.907 38.888 41.13 80.902 84.938 42.015 43.81 78.982 82.803 82.148 86.653 22.513 27.369 37.939 62.936 42.985 99.106 2.02 14.476 1.544 48.204-.86 60.894-9.463 49.978-33.517 90.66-71.83 121.486-12.776 10.28-23.737 16.95-51.36 31.254-12.65 6.55-23.336 12.235-23.746 12.631-.41.397-.752 117.813-.758 260.925-.006 143.112-.12 260.16-.253 260.108-.134-.053-15.205-15.437-33.493-34.186zM178.906 638.85c71.23-36.528 104.55-53.835 109.959-57.114 6.26-3.795 6.135-2.348 6.135-71.234 0-54.931-.185-62.635-1.513-63.144-2.731-1.048-35.293 26.733-59.18 50.49-46.91 46.658-85.148 98.787-116.435 158.736-6.898 13.218-8.077 16.417-6.05 16.417.27 0 30.457-15.368 67.084-34.15z\"\n    fill=\"#d97587\"\n  />\n  <path\n    d=\"M327.16 1101.103L295 1068.147V860.073C295 683.996 294.788 652 293.622 652c-1.315 0-58.635 28.855-194.122 97.72-34.1 17.332-70.276 35.795-80.392 41.029-16.654 8.616-18.344 9.29-17.885 7.133C17.968 719.165 47.642 646.268 91.67 575.69c50.319-80.663 120.337-152.583 201.501-206.976 24.938-16.713 65.921-40.394 67.846-39.204.634.391.983 38.246.983 106.58 0 98.187.123 106.019 1.675 106.614 2.04.783 14.653-5.537 25.791-12.923 10.279-6.816 24.958-20.87 33.138-31.725 11.764-15.613 21.373-38.096 24.78-57.983 2.105-12.28 2.105-33.867 0-46.148-3.447-20.121-12.35-40.743-24.676-57.163-8.154-10.863-17.697-21.089-84.702-90.763-58.451-60.779-67.03-69.824-76.644-80.817-14.504-16.58-22.77-31.534-27.055-48.946-2.527-10.267-2.277-30.305.52-41.614 6.837-27.642 25.603-50.83 51.673-63.847 6.33-3.16 9.797-4.42 24.5-8.904 2.914-.888 9.258-1.352 18-1.316 16.454.069 26.526 2.602 43.5 10.941 18.595 9.135 32.773 21.513 46.99 41.022C430.063 67.025 453.562 104.93 525.807 224 554.698 271.62 562 284.313 562 286.916c0 2.843-5.027-.722-14.524-10.299-16.874-17.017-121.445-126.419-153.078-160.15-33.61-35.84-43.477-45.43-50.561-49.141-4.745-2.486-6.39-2.798-14.837-2.811-11.53-.018-16.395 2.038-24.099 10.183-6.812 7.203-9.28 12.982-9.311 21.802-.038 10.741 3.662 17.694 16.605 31.203 5.689 5.938 40.76 42.522 77.935 81.297s70.242 73.569 73.481 77.32c23.507 27.219 39.321 60.448 45.535 95.68 2.608 14.786 3.498 40.13 1.935 55.102-4.984 47.747-27.514 93.192-61.932 124.923-17.229 15.883-28.92 23.49-64.508 41.975L362.5 615.5l-.5 258.917c-.358 185.438-.81 259.02-1.59 259.28-.598.2-15.56-14.468-33.25-32.594zM127.256 665.611c7.835-4.064 41.245-21.236 74.245-38.16 77.212-39.598 87.475-45.044 90.5-48.03l2.5-2.466v-64.714c0-59.478-.134-64.74-1.659-65.033-.962-.185-7.889 4.88-16.5 12.066-56.396 47.063-101.47 99.65-139.07 162.25-9.957 16.58-27.271 48.53-27.271 50.327 0 2.218 3.134 1.085 17.255-6.24z\"\n    fill=\"#d5667a\"\n  />\n  <path\n    d=\"M327.75 1101.235L295 1068.43V860.215C295 667.803 294.875 652 293.358 652c-.903 0-24.865 11.762-53.25 26.137C144.001 726.81 7.518 796.225 4.126 798.157L.752 800.08l.543-2.29c.298-1.259 1.901-8.142 3.562-15.296C25.594 693.17 67.827 603.746 125.44 527.17c58.414-77.641 139.758-147.339 224.81-192.623l10.75-5.724V434.33c0 58.028.273 106.218.607 107.088.969 2.524 4.331 1.867 12.826-2.507 38.606-19.879 64.975-55.185 73.125-97.911 2.63-13.787 2.443-36.197-.413-49.688-4.269-20.157-12.777-39.485-24.039-54.604-7.25-9.734-18.523-21.83-77.545-83.208-81.359-84.605-87.75-91.517-96.375-104.233-25.64-37.798-21.321-85.87 10.656-118.607C272.428 17.774 289.484 7.686 305 3.949c3.025-.729 6.032-1.72 6.682-2.203C313.516.384 333.856-.305 340 .787c23.245 4.133 44.424 14.935 61.584 31.41 16.972 16.292 23.318 26.092 119.576 184.623 20.812 34.276 37.84 62.558 37.84 62.849s.734 2.075 1.63 3.966c4.183 8.813-3.137 3.391-21.84-16.18-8.96-9.374-40.388-42.224-69.84-73-29.454-30.775-63.722-66.755-76.152-79.955-26.609-28.258-41.921-43.39-46.88-46.331-5.439-3.225-15.396-5.47-20.805-4.69-11.248 1.623-21.977 9.718-26.936 20.325-3.73 7.978-3.764 18.346-.083 26.21 3.182 6.802 2.333 5.882 84.885 91.986 77.4 80.73 81.12 84.68 87.237 92.636 28.46 37.015 43.36 83.533 41.436 129.364-2.802 66.699-35.419 123.734-91.652 160.268-4.675 3.037-19.07 10.997-31.988 17.688-12.919 6.69-24.28 12.882-25.248 13.758-1.67 1.511-1.773 14.805-2.012 259.96l-.252 258.367zM179 639.31c100.466-51.503 109.16-56.12 112.757-59.886l3.258-3.41-.258-64.673c-.188-47.325-.563-64.775-1.397-65.053-.627-.21-6.688 4.165-13.468 9.72-67.106 54.986-121.539 122.037-162.018 199.576-8.062 15.443-9.337 19.186-6.124 17.968.963-.365 31.225-15.774 67.25-34.242z\"\n    fill=\"#d25b70\"\n  />\n  <path\n    d=\"M328.25 1101.735L296 1069.43v-207.28c0-144.403-.322-207.883-1.062-209.266-.998-1.864-5.059.034-66.75 31.202-36.129 18.252-101.338 51.35-144.91 73.55C39.704 779.837 3.63 798 3.11 798s-1.17.563-1.448 1.25c-.299.742-.536.533-.583-.514-.043-.97.346-2.545.866-3.5.52-.955 2.145-7.361 3.612-14.236 8.921-41.82 27.363-93.17 50.28-140 63.062-128.872 161.243-232.003 288.302-302.839 8.051-4.488 15.139-8.161 15.75-8.161.808 0 1.111 28.917 1.111 105.965 0 122.445-1.264 110.345 10.917 104.508 39.28-18.825 66.962-55.043 75.74-99.099 2.63-13.195 2.39-37.577-.501-51.108-4.936-23.094-16.02-45.416-31.168-62.766-6.882-7.882-29.895-32.087-76.534-80.5-82.54-85.678-89.136-93.15-97.437-110.388-5.882-12.215-8.107-21.218-8.74-35.373-.834-18.667 1.89-31.712 9.968-47.74 6.376-12.648 19.1-27.137 31.063-35.367 7.09-4.878 21.771-11.674 29.328-13.575 3.775-.95 7.595-2.108 8.49-2.573C314.88.551 332.166-.194 338.5.847c23.647 3.887 45.132 14.496 62.562 30.893 15.33 14.422 25.998 30.627 96.937 147.26C557.51 276.841 562 284.43 562 287.156c0 1.149-3.132-.192-6.018-2.576-3.82-3.155-117.57-121.818-160.643-167.58-46.501-49.404-51.558-53.48-66.36-53.493-6.49-.006-8.437.444-14.197 3.28-7.387 3.636-13.302 9.575-16.985 17.05-1.855 3.766-2.294 6.298-2.283 13.163.012 7.343.393 9.223 2.797 13.81 3.553 6.779 1.882 4.974 81.665 88.19 82.47 86.018 84.388 88.062 91.415 97.399 55.323 73.507 53.499 175.341-4.41 246.101-7.075 8.646-21.32 22.685-29.981 29.549-13.18 10.445-29.27 19.986-59.5 35.283-7.15 3.618-13.675 7.032-14.5 7.587-1.325.89-1.558 31.28-2 260.065l-.5 259.057zM178.058 639.938c95.683-49.053 110.963-57.112 113.96-60.11 3.782-3.782 3.967-7.187 3.975-73.387.006-46.062-.25-57.411-1.33-58.887-.736-1.006-1.836-1.655-2.445-1.442-3.488 1.222-36.65 30.329-54.557 47.888-29.687 29.11-50.436 53.247-73.067 85-18.205 25.542-36.173 55.164-48.971 80.733-5.388 10.764-6.57 14.267-4.815 14.267.444 0 30.706-15.328 67.25-34.062z\"\n    fill=\"#d0556b\"\n  />\n  <path\n    d=\"M327.586 1100.804L296 1068.608V861.386c0-113.972-.273-207.934-.607-208.804-.334-.87-1.025-1.582-1.535-1.582-.894 0-67.557 33.673-219.358 110.804-39.6 20.12-72.33 37.058-72.735 37.64-.44.633-.74.38-.75-.635-.008-.93.383-1.937.87-2.238.486-.3 1.202-2.69 1.59-5.309C6.147 773.24 20.821 724.29 32.483 694.5 67.59 604.824 119.366 525.056 186 457.987c42.53-42.806 88.461-78.72 139-108.683C338.878 341.076 359.152 330 360.334 330c.366 0 .666 47.645.666 105.878 0 94.212.17 106.018 1.543 107.158 2.258 1.874 19.833-7.124 32.386-16.58 61.94-46.66 73.247-134.136 25.108-194.258-6.766-8.45-23.659-26.376-76.047-80.698-86.024-89.2-92.468-96.457-101.218-114-6.878-13.792-9.78-26.11-9.759-41.434.049-35.49 20.772-68.348 53.487-84.812C292.862 8.053 307.84 3 310.968 3c1.026 0 2.044-.537 2.263-1.194.533-1.598 27.482-1.763 30.867-.188 1.32.614 5.987 2.108 10.37 3.32 24.509 6.775 46.81 23.356 64.754 48.143 8.722 12.048 31.642 48.756 76.438 122.419 58.43 96.083 63.688 104.871 64.13 107.184.244 1.274.84 2.316 1.327 2.316.485 0 .847.788.804 1.75-.048 1.056-.28 1.252-.585.494-.279-.691-.952-.982-1.495-.645s-2.142-.45-3.55-1.744c-4.834-4.445-133.887-139.356-163.924-171.365-34.462-36.726-40.99-42.981-49.047-47-5.016-2.501-6.994-2.908-14.32-2.946-7.31-.038-9.287.343-14.125 2.718-6.351 3.12-13.588 10.313-16.948 16.846-1.551 3.017-2.394 6.74-2.69 11.892-.527 9.135 1.765 16.13 7.592 23.175 2.126 2.57 38.315 40.596 80.42 84.5 42.103 43.904 79.382 83.2 82.84 87.325 31.985 38.152 48.936 91.349 44.93 141-3.996 49.52-24.54 94.009-58.676 127.063-18.255 17.677-29.872 25.551-63.343 42.936-12.925 6.713-24.513 12.845-25.75 13.626l-2.25 1.421v258.477c0 155.373-.364 258.477-.914 258.477-.502 0-15.127-14.488-32.5-32.196zm-150.782-459.99c88.023-45.152 111.172-57.277 114.556-60.002 2.15-1.73 3.09-3.592 3.702-7.316.94-5.735 1.25-123.52.331-125.914-1.035-2.697-3.413-1.79-10.782 4.116C218.314 504.82 162.855 571.496 121.745 647.5c-12.735 23.546-13.92 26.5-10.632 26.5.548 0 30.109-14.934 65.691-33.186z\"\n    fill=\"#ce4b62\"\n  />\n  <path\n    d=\"M327.75 1100.557L296 1068.204V860.07c0-182.235-.183-208.204-1.47-208.698-1.466-.562-58.544 28.01-209.53 104.886-43.175 21.982-79.352 40.418-80.394 40.967-1.774.935-1.852.684-1.232-3.99.364-2.743 2.246-11.456 4.183-19.362C33.185 669.25 88.65 565.17 162.567 483c13.052-14.508 43.472-44.676 57.933-57.45 22.863-20.199 52.818-42.986 79-60.097C318.2 353.23 358.071 330 360.347 330c.354 0 .758 48.031.898 106.736.292 122.05-.945 109.652 10.405 104.34 26.462-12.383 53.634-39.656 65.522-65.766 9.386-20.615 12.303-34.442 12.303-58.31 0-25.853-3.409-40.255-14.79-62.486-9.712-18.97-12.905-22.605-92.663-105.514-54.287-56.432-69.664-72.587-78.525-82.5-14.495-16.216-23.26-31.269-27.66-47.5-3.189-11.766-3.191-33.593-.005-45.5 2.96-11.058 11.053-26.995 18.255-35.948 13.295-16.528 34.218-29.645 53.704-33.67 3.352-.692 5.904-1.566 5.67-1.943-.232-.376 3.21-1.001 7.65-1.388 9.024-.788 23.158.204 22.317 1.565-.3.486.582.884 1.961.884 3.985 0 18.418 4.845 26.745 8.979 17.148 8.511 32.673 22.09 46.332 40.521 8.797 11.871 31.371 48.02 83.057 133C551.678 267.963 560 282.073 560 284.645c0 4.476 6.208 10.695-98.411-98.588-20.301-21.207-49.375-51.832-64.61-68.057-49.123-52.318-51.874-54.508-68.479-54.49-7.812.007-9.672.362-14.086 2.683-6.37 3.35-13.68 10.686-16.645 16.703-1.88 3.816-2.266 6.23-2.254 14.104.013 8.426.328 10.098 2.785 14.786 3.181 6.07-.292 2.344 89.202 95.714 37.429 39.05 70.085 73.25 72.57 76 26.679 29.523 43.368 65.314 49.427 106 1.97 13.23 1.939 40.293-.063 54.648-7.709 55.268-38.303 104.75-83.936 135.757-11.08 7.53-25.12 15.429-45.5 25.601-7.425 3.706-14.737 7.614-16.25 8.684l-2.75 1.946v258.432c0 142.138-.337 258.412-.75 258.387-.412-.025-15.037-14.604-32.5-32.398zM161.5 648.915c61.67-31.553 116.508-59.915 124.116-64.192 3.217-1.809 6.87-4.803 8.116-6.654l2.268-3.365v-62.77c0-34.524-.273-63.482-.607-64.352-1.24-3.23-4.701-1.284-19.195 10.786-59.347 49.424-107.998 107.202-145.622 172.94C120.724 648.525 109 671.17 109 672.986c0 .558.788 1.012 1.75 1.007.963-.004 23.8-11.289 50.75-25.077zM1.079 798.417c.048-1.165.285-1.402.604-.604.289.721.253 1.584-.079 1.916-.332.332-.568-.258-.525-1.312zm560-512c.048-1.165.285-1.402.604-.604.289.721.253 1.584-.079 1.916-.332.332-.568-.258-.525-1.312z\"\n    fill=\"#cb425b\"\n  />\n  <path\n    d=\"M327.73 1100.214L296 1068.428V651h-2.035c-1.923 0-56.482 27.428-219.261 110.23-38.938 19.806-71.04 35.768-71.337 35.47-1.52-1.52 9.152-43.123 17.623-68.7 23.785-71.808 59.687-140.184 105.537-201 49.378-65.496 114.119-124.602 184.973-168.877C329.428 346.92 357.718 331 359.697 331c1.049 0 1.342 20.126 1.548 106.25l.255 106.25 2.158.307c1.187.17 5.912-1.575 10.5-3.877 38.938-19.534 67.143-57.46 74.26-99.856 2.028-12.085 2.052-34.088.05-45.693-4.108-23.8-14.978-47.207-30.343-65.34-6.59-7.778-32.566-35.198-78.165-82.513-84.916-88.111-88.312-91.975-97.574-111.028-6.803-13.994-8.666-22.379-8.666-39 0-15.652 1.62-23.666 7.564-37.413 11.305-26.15 36.416-47.329 64.548-54.44 5.04-1.274 8.925-2.556 8.632-2.85s2.653-.846 6.546-1.23c8.325-.82 23.832.284 21.828 1.554-.945.6-.623.853 1.098.864 4.246.026 17.273 4.074 25.647 7.97 18.037 8.388 33.823 21.762 47.803 40.494 10.75 14.404 43.312 67.044 130.866 211.551 13.182 21.756 15.102 27.576 6.516 19.75-1.962-1.787-24.854-25.525-50.872-52.75s-56.343-58.95-67.388-70.5-30.036-31.575-42.203-44.5c-45.037-47.847-49.104-51.15-63.805-51.812-10.82-.488-17.585 2.03-25.058 9.325-9.576 9.35-13.177 20.97-9.955 32.13 3.2 11.09-3.766 3.238 85.494 96.357 37.168 38.775 71.184 74.351 75.592 79.058 27.976 29.873 44.618 63.305 51.991 104.442 2.165 12.077 2.972 39.39 1.549 52.404-6.68 61.083-38.546 112.843-90.113 146.368-4.675 3.039-19.75 11.386-33.5 18.549l-25 13.023-.252 258.578c-.138 142.218-.598 258.578-1.02 258.578s-15.048-14.304-32.499-31.786zM173.832 642.869c80.917-41.488 111.912-57.595 115.822-60.186 6.661-4.416 6.345-.855 6.345-71.387 0-63.505-.133-66.296-3.161-66.296-.419 0-6.066 4.348-12.55 9.661-50.501 41.388-95.09 90.731-129.53 143.339-19.047 29.097-42.722 72.049-41.486 75.269.365.952.94 1.731 1.279 1.731s28.815-14.459 63.281-32.131zM1.08 798.417c.048-1.165.285-1.402.604-.604.289.721.253 1.584-.079 1.916-.332.332-.568-.258-.525-1.312z\"\n    fill=\"#c93953\"\n  />\n  <path\n    d=\"M328.25 1100.224L297 1068.218V861.674c0-135.382-.345-207.45-1-209.176-.774-2.034-1.46-2.487-3.022-1.991-2.238.71-100.47 50.382-214.87 108.65C37.19 779.997 3.58 796.914 3.415 796.75c-.164-.164.752-5.256 2.035-11.316 16.327-77.086 55.85-165.347 106.586-238.022C166.5 469.399 240.567 400.685 324 350.764 336.193 343.47 358.613 331 359.537 331c.255 0 .463 47.213.463 104.918 0 57.705.273 105.63.607 106.5 2.52 6.565 30.402-9.406 49.532-28.37 49.813-49.385 54.305-127.742 10.457-182.425-6.866-8.563-22.286-24.92-82.603-87.623-61.223-63.645-69.778-72.703-78.449-83.061-18.205-21.748-25.523-40.122-25.537-64.124-.01-17.252 3.075-29.725 11.062-44.715 8.938-16.774 23.304-30.513 41.431-39.622 10.152-5.102 23.157-9.337 29.41-9.578l2.59-.1-2.5-.727C311.921.884 327.625-.26 336.121.606c5.5.56 6.743.935 4.879 1.473-2.396.692-2.372.726.562.821 5.692.186 18.888 4.212 28.172 8.597 18.458 8.717 33.443 21.395 47.493 40.18 8.555 11.437 35.234 54.152 85.459 136.823 57.13 94.037 59.135 97.5 56.466 97.5-.541 0-30.817-31.162-67.28-69.25-87.145-91.027-80.526-84.078-104.542-109.75-28.06-29.996-36.048-37.507-43.666-41.062-14.815-6.912-29.076-3.92-40.503 8.496-5.602 6.087-8.35 12.426-8.91 20.55-.505 7.318 1.714 14.724 6.534 21.812 1.493 2.196 37.408 40.153 79.81 84.349 42.403 44.195 79.66 83.28 82.793 86.855 12.61 14.386 25.967 36.549 33.507 55.6 29.655 74.922 10.459 161.952-47.782 216.628-16.817 15.787-31.084 25.088-66.355 43.253-11.407 5.875-21.082 11.208-21.5 11.85S360.275 732.54 360 874.366l-.5 257.865zm-161.562-453.33c78.502-40.254 118.26-60.875 122.537-63.554 7.716-4.833 7.237-.462 7.592-69.395.175-33.905-.078-63.175-.563-65.044-.505-1.951-1.614-3.54-2.603-3.73-3.507-.675-36.64 27.777-62.66 53.811-47.335 47.357-87.336 102.84-118.464 164.312-4.59 9.062-5.048 11.706-2.03 11.706.758 0 26.044-12.648 56.19-28.106zM1.078 798.417c.049-1.165.286-1.402.605-.604.289.721.253 1.584-.079 1.916-.332.332-.568-.258-.525-1.312z\"\n    fill=\"#c7324d\"\n  />\n  <path\n    d=\"M328.252 1099.5l-31.247-31.822-.253-208.589L296.5 650.5l-2.04-.292c-1.971-.282-58.227 27.914-216.361 108.444C37.729 779.21 4.466 795.8 4.183 795.517c-.283-.283.497-5.128 1.734-10.766 21.26-96.963 75.455-204.843 142.873-284.398 57.034-67.301 124.983-122.468 203.46-165.186l7.75-4.219v213.014l2.504.628c6.466 1.623 32.916-14.924 48.031-30.049 15.899-15.908 26.29-32.795 33.488-54.42 13.143-39.492 7.254-84.164-15.66-118.776-8.067-12.187-17.296-22.226-85.81-93.345-74.81-77.652-82.796-86.298-91.63-99.195-6.776-9.89-11.099-18.949-14.224-29.805-3.178-11.039-3.118-34.126.117-45.5 6.299-22.146 21.005-42.559 39.512-54.842 11.954-7.935 31.62-15.402 41.61-15.8l3.562-.142-4-.66C310.545.908 329.424-.306 337 .802c5.7.834 6.007.981 2.5 1.197-3.645.223-3.467.321 2 1.101 28.193 4.024 55.738 22.151 76.444 50.307 9.458 12.862 93.209 148.737 135.38 219.637 6.423 10.8 7.239 13.8 2.553 9.393-5.93-5.577-133.602-139.034-164.053-171.486C356.868 73.697 352.921 69.958 344 65.644c-5.827-2.819-7.432-3.142-15.5-3.124-11.184.025-16.444 2.287-24.65 10.6-7.698 7.802-10.202 14.409-9.618 25.38.475 8.914 2.922 14.904 9.115 22.317 1.927 2.307 34.257 36.254 71.844 75.439 37.587 39.184 73.03 76.194 78.76 82.244 27.592 29.125 44.684 61.242 52.67 98.967 3.49 16.488 4.454 49.262 1.958 66.533-5.152 35.641-20.228 70.155-42.59 97.5-8.565 10.475-26.334 27.133-37.023 34.71-12.714 9.011-18.287 12.242-45.35 26.29l-23.116 12-.5 258.412-.5 258.411zm-160.32-452.727c67.812-34.767 112.984-58.127 119.71-61.907 9.544-5.363 8.819.337 9.145-71.866.158-34.925.037-64.513-.269-65.75-.989-4-4.972-2.82-13.347 3.96-70.88 57.372-124.32 122.996-168.513 206.935-7.753 14.724-8.554 18.909-3.3 17.241 1.103-.35 26.56-13.226 56.573-28.613zM1.078 798.417c.048-1.165.285-1.402.604-.604.289.721.253 1.584-.079 1.916-.332.332-.568-.258-.525-1.312z\"\n    fill=\"#c62b47\"\n  />\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/navigate.css",
    "content": ".nav-item {\n  border-bottom: 2px solid;\n}\n\n.svg-container {\n  margin: 16px;\n  display: flex; /* Using Flexbox for alignment */\n  height: 64px;\n  align-items: center; /* Vertically center the SVG */\n  justify-content: flex-start; /* Align the SVG to the left */\n}\n\n.svg-container:hover {\n  filter: drop-shadow(0px 0px 8px);\n}\n\n.svg-container svg {\n  height: 100%;\n  width: auto;\n  display: block;\n}\n"
  },
  {
    "path": "admin_frontend/assets/postgres/logo.html",
    "content": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 25.6 25.6\">\n  <style>\n    <![CDATA[.B{stroke-linecap:round}.C{stroke-linejoin:round}.D{stroke-linejoin:miter}.E{stroke-width:.716}]]>\n  </style>\n  <g fill=\"none\" stroke=\"#fff\">\n    <path\n      d=\"M18.983 18.636c.163-1.357.114-1.555 1.124-1.336l.257.023c.777.035 1.793-.125 2.4-.402 1.285-.596 2.047-1.592.78-1.33-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.227-11.687-3.004-3.84-8.205-2.024-8.292-1.976l-.028.005c-.57-.12-1.2-.19-1.93-.2-1.308-.02-2.3.343-3.054.914 0 0-9.277-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.01 2.01 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.285 1.76.33 2.842s.116 2.093.337 2.688.48 2.13 2.53 1.7c1.713-.367 3.023-.896 3.143-5.81\"\n      fill=\"#000\"\n      stroke=\"#000\"\n      stroke-linecap=\"butt\"\n      stroke-width=\"2.149\"\n      class=\"D\"\n    />\n    <path\n      d=\"M23.535 15.6c-2.89.596-3.1-.383-3.1-.383 3.053-4.53 4.33-10.28 3.228-11.687-3.004-3.84-8.205-2.023-8.292-1.976l-.028.005a10.31 10.31 0 0 0-1.929-.201c-1.308-.02-2.3.343-3.054.914 0 0-9.278-3.822-8.846 4.807.092 1.836 2.63 13.9 5.66 10.25C8.29 15.987 9.36 14.86 9.36 14.86c.53.353 1.167.533 1.834.468l.052-.044a2.02 2.02 0 0 0 .021.518c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.162l-.046.183c.306.245.52 1.593.484 2.815s-.06 2.06.18 2.716.48 2.13 2.53 1.7c1.713-.367 2.6-1.32 2.725-2.906.088-1.128.286-.962.3-1.97l.16-.478c.183-1.53.03-2.023 1.085-1.793l.257.023c.777.035 1.794-.125 2.39-.402 1.285-.596 2.047-1.592.78-1.33z\"\n      fill=\"#336791\"\n      stroke=\"none\"\n    />\n    <g class=\"E\">\n      <g class=\"B\">\n        <path\n          d=\"M12.814 16.467c-.08 2.846.02 5.712.298 6.4s.875 2.05 2.926 1.612c1.713-.367 2.337-1.078 2.607-2.647l.633-5.017M10.356 2.2S1.072-1.596 1.504 7.033c.092 1.836 2.63 13.9 5.66 10.25C8.27 15.95 9.27 14.907 9.27 14.907m6.1-13.4c-.32.1 5.164-2.005 8.282 1.978 1.1 1.407-.175 7.157-3.228 11.687\"\n          class=\"C\"\n        />\n        <path\n          d=\"M20.425 15.17s.2.98 3.1.382c1.267-.262.504.734-.78 1.33-1.054.49-3.418.615-3.457-.06-.1-1.745 1.244-1.215 1.147-1.652-.088-.394-.69-.78-1.086-1.744-.347-.84-4.76-7.29 1.224-6.333.22-.045-1.56-5.7-7.16-5.782S7.99 8.196 7.99 8.196\"\n          stroke-linejoin=\"bevel\"\n        />\n      </g>\n      <g class=\"C\">\n        <path\n          d=\"M11.247 15.768c-.78.872-.55 1.025-2.11 1.346-1.578.325-.65.904-.046 1.056.734.184 2.432.444 3.58-1.163.35-.49-.002-1.27-.482-1.468-.232-.096-.542-.216-.94.23z\"\n        />\n        <path\n          d=\"M11.196 15.753c-.08-.513.168-1.122.433-1.836.398-1.07 1.316-2.14.582-5.537-.547-2.53-4.22-.527-4.22-.184s.166 1.74-.06 3.365c-.297 2.122 1.35 3.916 3.246 3.733\"\n          class=\"B\"\n        />\n      </g>\n    </g>\n    <g fill=\"#fff\" class=\"D\">\n      <path\n        d=\"M10.322 8.145c-.017.117.215.43.516.472s.558-.202.575-.32-.215-.246-.516-.288-.56.02-.575.136z\"\n        stroke-width=\".239\"\n      />\n      <path\n        d=\"M19.486 7.906c.016.117-.215.43-.516.472s-.56-.202-.575-.32.215-.246.516-.288.56.02.575.136z\"\n        stroke-width=\".119\"\n      />\n    </g>\n    <path\n      d=\"M20.562 7.095c.05.92-.198 1.545-.23 2.524-.046 1.422.678 3.05-.413 4.68\"\n      class=\"B C E\"\n    />\n  </g>\n</svg>\n"
  },
  {
    "path": "admin_frontend/assets/sidebar.css",
    "content": "#sidebar {\n  display: flex;\n  flex-direction: column;\n  width: 128px;\n  height: 100vh;\n}\n\n.sidebar-item {\n  padding: 16px 16px; /* Padding to give button-like space */\n  cursor: pointer; /* Changing the cursor on hover to indicate clickability */\n}\n\n.sidebar-item:hover {\n  text-shadow: 0 0 8px;\n}\n"
  },
  {
    "path": "admin_frontend/assets/top_menu_bar.css",
    "content": "#top-menu-bar {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n#top-menu-bar-left {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n\n#top-menu-bar-right {\n  display: flex;\n  text-align: right;\n}\n"
  },
  {
    "path": "admin_frontend/dev.env",
    "content": "GOTRUE_URL=http://localhost:9999\nREDIS_URL=redis://localhost:6379\nRUST_LOG=trace\n"
  },
  {
    "path": "admin_frontend/src/askama_entities.rs",
    "content": "use database_entity::dto::AFWorkspace;\n\nuse crate::ext::entities::WorkspaceMember;\n\npub struct WorkspaceWithMembers {\n  pub workspace: AFWorkspace,\n  pub members: Vec<WorkspaceMember>,\n}\n"
  },
  {
    "path": "admin_frontend/src/config.rs",
    "content": "use tracing::warn;\n\n#[derive(Debug, Clone)]\npub struct Config {\n  pub host: String,\n  pub port: u16,\n  pub redis_url: String,\n  pub gotrue_url: String,\n  pub appflowy_cloud_url: String,\n  pub oauth: OAuthConfig,\n  pub path_prefix: String,\n}\n\n#[derive(Debug, Clone)]\npub struct OAuthConfig {\n  pub client_id: String,\n  pub client_secret: Option<String>,\n  pub allowable_redirect_uris: Vec<String>,\n}\n\nimpl Config {\n  pub fn from_env() -> Result<Config, anyhow::Error> {\n    let cfg = Config {\n      host: get_or_default(\"ADMIN_FRONTEND_HOST\", \"0.0.0.0\"),\n      port: get_or_default(\"ADMIN_FRONTEND_PORT\", \"3000\")\n        .parse()\n        .map_err(|e| anyhow::anyhow!(\"failed to parse ADMIN_FRONTEND_PORT as u16, err: {}\", e))?,\n      redis_url: get_or_default(\"ADMIN_FRONTEND_REDIS_URL\", \"redis://localhost:6379\"),\n      gotrue_url: get_or_default(\"ADMIN_FRONTEND_GOTRUE_URL\", \"http://localhost:9999\"),\n      appflowy_cloud_url: get_or_default(\n        \"ADMIN_FRONTEND_APPFLOWY_CLOUD_URL\",\n        \"http://localhost:8000\",\n      ),\n      oauth: OAuthConfig {\n        client_id: get_or_default(\"ADMIN_FRONTEND_OAUTH_CLIENT_ID\", \"appflowy_cloud\"),\n        client_secret: get_optional(\"ADMIN_FRONTEND_OAUTH_CLIENT_SECRET\"),\n        allowable_redirect_uris: get_or_default(\n          \"ADMIN_FRONTEND_OAUTH_ALLOWABLE_REDIRECT_URIS\",\n          \"http://localhost:3000\",\n        )\n        .split(',')\n        .map(|s| s.to_string())\n        .collect(),\n      },\n      path_prefix: get_or_default(\"ADMIN_FRONTEND_PATH_PREFIX\", \"\"),\n    };\n    Ok(cfg)\n  }\n}\n\nfn get_or_default(key: &str, default: &str) -> String {\n  std::env::var(key).unwrap_or_else(|e| {\n    warn!(\n      \"failed to get env var: {}, err: {}, using default: {}\",\n      key, e, default\n    );\n    default.to_string()\n  })\n}\n\nfn get_optional(key: &str) -> Option<String> {\n  let s = match std::env::var(key) {\n    Ok(s) => s,\n    Err(err) => {\n      warn!(\"failed to get env var: {}, err: {}\", key, err);\n      return None;\n    },\n  };\n  if s.is_empty() {\n    warn!(\"env var: {} is empty\", key);\n    None\n  } else {\n    Some(s)\n  }\n}\n"
  },
  {
    "path": "admin_frontend/src/error.rs",
    "content": "use std::borrow::Cow;\n\nuse axum::{\n  http::{status, StatusCode},\n  response::{IntoResponse, Redirect},\n};\n\nuse crate::ext;\n\npub struct WebApiError<'a> {\n  pub status_code: status::StatusCode,\n  pub payload: Cow<'a, str>,\n}\n\nimpl<'a> WebApiError<'a> {\n  pub fn new<S>(status_code: status::StatusCode, payload: S) -> Self\n  where\n    S: Into<Cow<'a, str>>,\n  {\n    WebApiError {\n      status_code,\n      payload: payload.into(),\n    }\n  }\n}\n\nimpl IntoResponse for WebApiError<'_> {\n  fn into_response(self) -> axum::response::Response {\n    let status = self.status_code;\n    let payload = self.payload.into_owned(); // Converts Cow<str> into String\n    (status, payload).into_response()\n  }\n}\n\nimpl From<gotrue_entity::error::GoTrueError> for WebApiError<'_> {\n  fn from(v: gotrue_entity::error::GoTrueError) -> Self {\n    WebApiError::new(status::StatusCode::UNAUTHORIZED, v.to_string())\n  }\n}\n\nimpl From<redis::RedisError> for WebApiError<'_> {\n  fn from(v: redis::RedisError) -> Self {\n    WebApiError::new(status::StatusCode::INTERNAL_SERVER_ERROR, v.to_string())\n  }\n}\n\npub enum WebAppError {\n  Askama(askama::Error),\n  LoginRedirectRequired(String),\n  ExtApi(ext::error::Error),\n  Redis(redis::RedisError),\n  BadRequest(String),\n}\n\nimpl IntoResponse for WebAppError {\n  fn into_response(self) -> axum::response::Response {\n    match self {\n      WebAppError::Askama(e) => {\n        tracing::error!(\"askama error: {:?}\", e);\n        status::StatusCode::INTERNAL_SERVER_ERROR.into_response()\n      },\n      WebAppError::LoginRedirectRequired(base_path) => {\n        Redirect::to(&format!(\"{}/login\", base_path)).into_response()\n      },\n      WebAppError::ExtApi(e) => e.into_response(),\n      WebAppError::Redis(e) => {\n        tracing::error!(\"redis error: {:?}\", e);\n        status::StatusCode::INTERNAL_SERVER_ERROR.into_response()\n      },\n      WebAppError::BadRequest(e) => {\n        tracing::error!(\"bad request: {:?}\", e);\n        status::StatusCode::BAD_REQUEST.into_response()\n      },\n    }\n  }\n}\n\nimpl From<redis::RedisError> for WebAppError {\n  fn from(v: redis::RedisError) -> Self {\n    WebAppError::Redis(v)\n  }\n}\n\nimpl From<askama::Error> for WebAppError {\n  fn from(v: askama::Error) -> Self {\n    WebAppError::Askama(v)\n  }\n}\n\nimpl From<ext::error::Error> for WebAppError {\n  fn from(v: ext::error::Error) -> Self {\n    WebAppError::ExtApi(v)\n  }\n}\n\nimpl From<ext::error::Error> for WebApiError<'_> {\n  fn from(v: ext::error::Error) -> Self {\n    match v {\n      ext::error::Error::NotOk(code, payload) => {\n        WebApiError::new(StatusCode::from_u16(code).unwrap(), payload)\n      },\n      err => WebApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!(\"{:?}\", err)),\n    }\n  }\n}\n"
  },
  {
    "path": "admin_frontend/src/ext/api.rs",
    "content": "use database_entity::dto::{AFRole, AFWorkspace, AFWorkspaceInvitation};\nuse shared_entity::dto::{auth_dto::SignInTokenResponse, workspace_dto::WorkspaceMemberInvitation};\n\nuse super::{\n  check_response,\n  entities::{\n    UserProfile, UserUsageLimit, WorkspaceBlobUsage, WorkspaceDocUsage, WorkspaceMember,\n    WorkspaceUsageLimits,\n  },\n  error::Error,\n  from_json_response,\n};\n\npub async fn get_user_owned_workspaces(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<AFWorkspace>, Error> {\n  let user_profile = get_user_profile(access_token, appflowy_cloud_base_url).await?;\n  let owned_workspaces = get_user_workspaces(access_token, appflowy_cloud_base_url)\n    .await?\n    .into_iter()\n    .filter(|w| w.owner_uid == user_profile.uid)\n    .collect::<Vec<_>>();\n  Ok(owned_workspaces)\n}\n\npub async fn get_user_workspaces(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<AFWorkspace>, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\"{}/api/workspace\", appflowy_cloud_base_url))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\npub async fn get_user_workspace_limit(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<UserUsageLimit, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\"{}/api/user/limit\", appflowy_cloud_base_url))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\npub async fn get_user_workspace_usages(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<WorkspaceUsageLimits>, Error> {\n  let user_workspaces = get_user_owned_workspaces(access_token, appflowy_cloud_base_url).await?;\n\n  let mut workspace_usages: Vec<WorkspaceUsageLimits> = Vec::with_capacity(user_workspaces.len());\n  for user_workspace in user_workspaces {\n    let workspace_id = user_workspace.workspace_id.to_string();\n    let members =\n      get_workspace_members(&workspace_id, access_token, appflowy_cloud_base_url).await?;\n    let total_blob_size =\n      get_user_workspace_blob_usage(&workspace_id, access_token, appflowy_cloud_base_url)\n        .await\n        .map(|u| human_bytes::human_bytes(u.consumed_capacity as f64))\n        .unwrap_or_else(|err| {\n          tracing::error!(\"Error getting user workspace blob usage: {:?}\", err);\n          \"0\".to_owned()\n        });\n    let total_doc_size = {\n      get_user_workspace_doc_usage(&workspace_id, access_token, appflowy_cloud_base_url)\n        .await\n        .map(|u| human_bytes::human_bytes(u.total_document_size as f64))\n        .unwrap_or_else(|err| {\n          tracing::error!(\"Error getting user workspace doc usage: {:?}\", err);\n          \"0\".to_owned()\n        })\n    };\n\n    workspace_usages.push(WorkspaceUsageLimits {\n      name: user_workspace.workspace_name,\n      member_count: members.len(),\n      total_doc_size,\n      total_blob_size,\n    });\n  }\n\n  Ok(workspace_usages)\n}\n\npub async fn get_workspace_members(\n  workspace_id: &str,\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<WorkspaceMember>, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\n      \"{}/api/workspace/{}/member\",\n      appflowy_cloud_base_url, workspace_id\n    ))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\npub async fn get_pending_workspace_invitations(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<AFWorkspaceInvitation>, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\n      \"{}/api/workspace/invite?status=Pending\",\n      appflowy_cloud_base_url\n    ))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\npub async fn get_accepted_workspace_invitations(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<Vec<AFWorkspaceInvitation>, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\n      \"{}/api/workspace/invite?status=Accepted\",\n      appflowy_cloud_base_url\n    ))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\nasync fn get_user_workspace_blob_usage(\n  workspace_id: &str,\n  access_token: &str,\n  appflowy_cloud_gateway_base_url: &str,\n) -> Result<WorkspaceBlobUsage, Error> {\n  let http_client = reqwest::Client::new();\n  let resp = http_client\n    .get(format!(\n      \"{}/api/file_storage/{}/usage\",\n      appflowy_cloud_gateway_base_url, workspace_id\n    ))\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\nasync fn get_user_workspace_doc_usage(\n  workspace_id: &str,\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<WorkspaceDocUsage, Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\n    \"{}/api/workspace/{}/usage\",\n    appflowy_cloud_base_url, workspace_id\n  );\n  let resp = http_client\n    .get(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n\n  from_json_response(resp).await\n}\n\npub async fn get_user_profile(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<UserProfile, Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\"{}/api/user/profile\", appflowy_cloud_base_url);\n  let resp = http_client\n    .get(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n  from_json_response(resp).await\n}\n\npub async fn invite_user_to_workspace(\n  access_token: &str,\n  workspace_id: &str,\n  user_email: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<(), Error> {\n  let invi = vec![WorkspaceMemberInvitation {\n    email: user_email.to_string(),\n    role: AFRole::Member,\n    skip_email_send: true,\n    ..Default::default()\n  }];\n\n  let http_client = reqwest::Client::new();\n  let url = format!(\n    \"{}/api/workspace/{}/invite\",\n    appflowy_cloud_base_url, workspace_id\n  );\n  let resp = http_client\n    .post(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .json(&invi)\n    .send()\n    .await?;\n\n  check_response(resp).await\n}\n\npub async fn leave_workspace(\n  access_token: &str,\n  workspace_id: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<(), Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\n    \"{}/api/workspace/{}/leave\",\n    appflowy_cloud_base_url, workspace_id\n  );\n  let resp = http_client\n    .post(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .json(&())\n    .send()\n    .await?;\n\n  check_response(resp).await\n}\n\npub async fn accept_workspace_invitation(\n  access_token: &str,\n  invite_id: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<(), Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\n    \"{}/api/workspace/accept-invite/{}\",\n    appflowy_cloud_base_url, invite_id\n  );\n  let resp = http_client\n    .post(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .json(&())\n    .send()\n    .await?;\n\n  check_response(resp).await\n}\n\npub async fn verify_token_cloud(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<(), Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\n    \"{}/api/user/verify/{}\",\n    appflowy_cloud_base_url, access_token\n  );\n  let resp = http_client\n    .get(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n  let _: SignInTokenResponse = from_json_response(resp).await?;\n  Ok(())\n}\n\npub async fn delete_current_user(\n  access_token: &str,\n  appflowy_cloud_base_url: &str,\n) -> Result<(), Error> {\n  let http_client = reqwest::Client::new();\n  let url = format!(\"{}/api/user\", appflowy_cloud_base_url);\n  let resp = http_client\n    .delete(url)\n    .header(\"Authorization\", format!(\"Bearer {}\", access_token))\n    .send()\n    .await?;\n  check_response(resp).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "admin_frontend/src/ext/entities.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n#[derive(Debug, Deserialize)]\n#[allow(dead_code)]\npub struct JsonResponse<T> {\n  pub code: u16,\n  pub data: T,\n}\n\n#[derive(Deserialize)]\npub struct UserUsageLimit {\n  pub workspace_count: i64,\n}\n\n#[derive(Serialize)]\npub struct WorkspaceUsageLimits {\n  pub name: String,\n  pub member_count: usize,\n  pub total_doc_size: String,\n  pub total_blob_size: String,\n}\n\n#[derive(Deserialize)]\n#[allow(dead_code)]\npub struct WorkspaceMember {\n  pub name: String,\n  pub email: String,\n  pub role: String,\n}\n\n#[derive(Deserialize, Serialize)]\npub struct WorkspaceBlobUsage {\n  pub consumed_capacity: u64,\n}\n\n#[derive(Deserialize)]\npub struct WorkspaceDocUsage {\n  pub total_document_size: i64,\n}\n\n#[derive(Deserialize)]\n#[allow(dead_code)]\npub struct UserProfile {\n  pub uid: i64,\n  pub uuid: Uuid,\n  pub email: Option<String>,\n  pub password: Option<String>,\n  pub name: Option<String>,\n  pub metadata: Option<serde_json::Value>,\n  pub encryption_sign: Option<String>,\n  pub latest_workspace_id: Uuid,\n  pub updated_at: i64,\n}\n"
  },
  {
    "path": "admin_frontend/src/ext/error.rs",
    "content": "use axum::response::{IntoResponse, Response};\nuse shared_entity::response::AppResponseError;\n\n#[derive(Debug)]\n#[allow(dead_code)]\npub enum Error {\n  NotOk(u16, String), // HTTP status code, payload\n  Reqwest(reqwest::Error),\n  AppFlowyCloud(AppResponseError),\n  Unhandled(String),\n}\n\nimpl From<reqwest::Error> for Error {\n  fn from(err: reqwest::Error) -> Self {\n    Error::Reqwest(err)\n  }\n}\n\nimpl IntoResponse for Error {\n  fn into_response(self) -> Response {\n    match self {\n      Error::NotOk(status_code, payload) => Response::builder()\n        .status(status_code)\n        .body(payload.into())\n        .unwrap(),\n      err => Response::builder()\n        .status(500)\n        .body(format!(\"Unhandled error: {:?}\", err).into())\n        .unwrap(),\n    }\n  }\n}\n"
  },
  {
    "path": "admin_frontend/src/ext/mod.rs",
    "content": "use shared_entity::response::{AppResponseError, ErrorCode};\n\nuse crate::ext::entities::JsonResponse;\n\npub mod api;\npub mod entities;\npub mod error;\n\nasync fn from_json_response<T>(resp: reqwest::Response) -> Result<T, error::Error>\nwhere\n  T: serde::de::DeserializeOwned,\n{\n  if !resp.status().is_success() {\n    let status = resp.status();\n    let payload = resp.text().await?;\n    return Err(error::Error::NotOk(status.as_u16(), payload));\n  }\n\n  let payload = resp.text().await?;\n  match serde_json::from_str::<JsonResponse<T>>(&payload) {\n    Ok(data) => Ok(data.data),\n    Err(_) => match serde_json::from_str::<AppResponseError>(&payload) {\n      Ok(af_cloud_err) => Err(error::Error::AppFlowyCloud(af_cloud_err)),\n      Err(err) => Err(error::Error::Unhandled(format!(\n        \"Failed to parse JSON response: {:?}, Payload: {}\",\n        err, payload\n      ))),\n    },\n  }\n}\n\nasync fn check_response(resp: reqwest::Response) -> Result<(), error::Error> {\n  let status = resp.status();\n  let payload = resp.text().await?;\n\n  if !status.is_success() {\n    return Err(error::Error::NotOk(status.as_u16(), payload));\n  }\n\n  if let Ok(cloud_err) = serde_json::from_str::<AppResponseError>(&payload) {\n    if cloud_err.code == ErrorCode::Ok {\n      return Ok(());\n    } else {\n      return Err(error::Error::AppFlowyCloud(cloud_err));\n    }\n  };\n\n  Ok(())\n}\n"
  },
  {
    "path": "admin_frontend/src/lib.rs",
    "content": "pub mod config;\npub mod models;\npub mod session;\n"
  },
  {
    "path": "admin_frontend/src/main.rs",
    "content": "mod askama_entities;\nmod config;\nmod error;\nmod ext;\nmod models;\nmod response;\nmod session;\nmod templates;\nmod web_api;\nmod web_app;\n\nuse axum::{response::Redirect, routing::get, Router};\nuse models::AppState;\nuse tokio::net::TcpListener;\nuse tower_http::services::ServeDir;\nuse tracing::info;\n\nuse crate::config::Config;\n\n#[tokio::main]\nasync fn main() {\n  // load from .env\n  dotenvy::dotenv().ok();\n\n  // set up tracing\n  tracing_subscriber::fmt()\n    .json()\n    .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())\n    .with_line_number(true)\n    .init();\n\n  let config = Config::from_env().unwrap();\n  info!(\"config loaded: {:?}\", &config);\n\n  let gotrue_client = gotrue::api::Client::new(reqwest::Client::new(), &config.gotrue_url);\n  gotrue_client\n    .health()\n    .await\n    .expect(\"gotrue health check failed\");\n  info!(\"Gotrue client initialized.\");\n\n  let redis_client = redis::Client::open(config.redis_url.clone())\n    .expect(\"failed to create redis client\")\n    .get_connection_manager()\n    .await\n    .expect(\"failed to get redis connection manager\");\n  info!(\"Redis client initialized.\");\n\n  let session_store = session::SessionStorage::new(redis_client);\n\n  let address = format!(\"{}:{}\", config.host, config.port);\n  let path_prefix = config.path_prefix.clone();\n  let state = AppState {\n    appflowy_cloud_url: config.appflowy_cloud_url.clone(),\n    gotrue_client,\n    session_store,\n    config,\n  };\n\n  let web_app_router = web_app::router(state.clone()).with_state(state.clone());\n  let web_api_router = web_api::router().with_state(state.clone());\n\n  let favicon_redirect_url = state.prepend_with_path_prefix(\"/assets/favicon.ico\");\n  let base_path_redirect_url = state.prepend_with_path_prefix(\"/web\");\n  let base_app = Router::new()\n    .route(\n      \"/favicon.ico\",\n      get(|| async {\n        let favicon_redirect_url = favicon_redirect_url;\n        Redirect::permanent(&favicon_redirect_url)\n      }),\n    )\n    .route(\n      \"/\",\n      get(|| async {\n        let base_path_redirect_url = base_path_redirect_url;\n        Redirect::permanent(&base_path_redirect_url)\n      }),\n    )\n    .nest_service(\"/web\", web_app_router)\n    .nest_service(\"/web-api\", web_api_router)\n    .nest_service(\"/assets\", ServeDir::new(\"assets\"));\n  let app = if path_prefix.is_empty() {\n    base_app\n  } else {\n    Router::new().nest(&path_prefix, base_app)\n  };\n\n  let listener = TcpListener::bind(address)\n    .await\n    .expect(\"failed to bind to port\");\n  info!(\"listening on: {:?}\", listener);\n  axum::serve(listener, app)\n    .await\n    .expect(\"failed to run server\");\n}\n"
  },
  {
    "path": "admin_frontend/src/models.rs",
    "content": "use serde::{Deserialize, Serialize};\n\nuse crate::{config::Config, session};\n\n#[derive(Clone)]\npub struct AppState {\n  pub appflowy_cloud_url: String,\n  pub gotrue_client: gotrue::api::Client,\n  pub session_store: session::SessionStorage,\n  pub config: Config,\n}\n\nimpl AppState {\n  pub fn prepend_with_path_prefix(&self, path: &str) -> String {\n    format!(\"{}{}\", self.config.path_prefix, path)\n  }\n}\n\n#[derive(Serialize, Deserialize)]\npub struct WebApiLoginRequest {\n  pub email: String,\n  pub password: String,\n  pub redirect_to: Option<String>,\n}\n\n#[derive(Deserialize)]\npub struct WebApiPutUserRequest {\n  pub password: String,\n}\n\n#[derive(Deserialize)]\npub struct WebApiChangePasswordRequest {\n  pub new_password: String,\n  pub confirm_password: String,\n}\n\n#[derive(Deserialize)]\npub struct WebApiAdminCreateUserRequest {\n  pub email: String,\n  pub password: String,\n  pub require_email_verification: bool,\n}\n\n#[derive(Deserialize)]\npub struct WebApiInviteUserRequest {\n  pub email: String,\n}\n\n#[derive(Deserialize)]\npub struct WebApiCreateSSOProviderRequest {\n  #[serde(rename = \"type\")]\n  pub type_: String,\n  pub metadata_url: String,\n}\n\n#[derive(Deserialize)]\npub struct WebAppOAuthLoginRequest {\n  // Use for Login\n  pub refresh_token: Option<String>,\n\n  // Use actions (with params) after login\n  pub action: Option<OAuthLoginAction>,\n\n  // Workspace Invitation\n  pub workspace_invitation_id: Option<String>,\n  pub workspace_name: Option<String>,\n  pub workspace_icon: Option<String>,\n  pub user_name: Option<String>,\n  pub user_icon: Option<String>,\n  pub workspace_member_count: Option<String>,\n\n  // Redirect\n  pub redirect_to: Option<String>,\n\n  // Errors\n  pub error: Option<String>,\n  pub error_code: Option<i64>,\n  pub error_description: Option<String>,\n}\n\n#[derive(Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum OAuthLoginAction {\n  AcceptWorkspaceInvite,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct OAuthRedirect {\n  pub client_id: String,\n  pub state: String,\n  pub redirect_uri: String,\n  pub response_type: String,\n  // pub scope: Option<String>,\n  pub code_challenge: Option<String>,\n  pub code_challenge_method: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Default)]\npub struct OAuthRedirectToken {\n  pub code: String,\n  pub client_id: Option<String>,\n  pub client_secret: Option<String>,\n  pub grant_type: String,\n  pub redirect_uri: Option<String>,\n  pub code_verifier: Option<String>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct LoginParams {\n  pub redirect_to: Option<String>,\n}\n"
  },
  {
    "path": "admin_frontend/src/response.rs",
    "content": "use std::borrow::Cow;\n\nuse axum::{response::IntoResponse, Json};\n\n#[derive(serde::Serialize)]\npub struct WebApiResponse<T>\nwhere\n  T: serde::Serialize,\n{\n  pub code: i16,\n  pub message: Cow<'static, str>,\n  pub data: T,\n}\n\nimpl<T> WebApiResponse<T>\nwhere\n  T: serde::Serialize,\n{\n  pub fn new(message: Cow<'static, str>, data: T) -> Self {\n    Self {\n      code: 0,\n      message,\n      data,\n    }\n  }\n}\n\nimpl<T> IntoResponse for WebApiResponse<T>\nwhere\n  T: serde::Serialize,\n{\n  fn into_response(self) -> axum::response::Response {\n    Json(self).into_response()\n  }\n}\n\nimpl<T> From<T> for WebApiResponse<T>\nwhere\n  T: serde::Serialize,\n{\n  fn from(data: T) -> Self {\n    Self::new(\"success\".into(), data)\n  }\n}\n\nimpl WebApiResponse<()> {\n  pub fn from_str(message: Cow<'static, str>) -> Self {\n    Self::new(message, ())\n  }\n}\n"
  },
  {
    "path": "admin_frontend/src/session.rs",
    "content": "use crate::models::AppState;\nuse std::time::{SystemTime, UNIX_EPOCH};\n\nuse axum::{\n  async_trait,\n  extract::{FromRequestParts, OriginalUri},\n  http::request::Parts,\n  response::{IntoResponse, Redirect},\n};\nuse axum_extra::extract::{cookie::Cookie, CookieJar};\nuse gotrue::grant::{Grant, RefreshTokenGrant};\nuse gotrue_entity::dto::GotrueTokenResponse;\nuse jwt::{Claims, Header};\nuse redis::{aio::ConnectionManager, AsyncCommands, FromRedisValue, ToRedisArgs};\nuse serde::{de::DeserializeOwned, Deserialize, Serialize};\n\nstatic SESSION_EXPIRATION: usize = 60 * 60 * 24; // 1 day\n\n#[derive(Clone)]\npub struct SessionStorage {\n  redis_client: ConnectionManager,\n}\n\nfn session_id_key(session_id: &str) -> String {\n  format!(\"web::session::{}\", session_id)\n}\n\nfn code_session_key(code: &str) -> String {\n  format!(\"web::session::code::{}\", code)\n}\n\nimpl SessionStorage {\n  pub fn new(redis_client: ConnectionManager) -> Self {\n    Self { redis_client }\n  }\n\n  pub async fn get_user_session(\n    &self,\n    session_id: &str,\n  ) -> Result<Option<UserSession>, redis::RedisError> {\n    let key = session_id_key(session_id);\n    let user_session_optional: UserSessionOptional = self.redis_client.clone().get(&key).await?;\n    Ok(user_session_optional.0)\n  }\n\n  pub async fn get_code_session(\n    &self,\n    code: &str,\n  ) -> Result<Option<CodeSession>, redis::RedisError> {\n    let key = code_session_key(code);\n    let code_session_optional: CodeSessionOptional = self.redis_client.clone().get(&key).await?;\n    Ok(code_session_optional.0)\n  }\n\n  pub async fn put_user_session(&self, user_session: &UserSession) -> redis::RedisResult<()> {\n    let key = session_id_key(&user_session.session_id);\n    self\n      .redis_client\n      .clone()\n      .set_options(\n        key,\n        user_session,\n        redis::SetOptions::default().with_expiration(redis::SetExpiry::EX(SESSION_EXPIRATION)),\n      )\n      .await\n  }\n\n  pub async fn del_user_session(&self, session_id: &str) -> redis::RedisResult<()> {\n    let key = session_id_key(session_id);\n    let res = self.redis_client.clone().del::<_, i64>(key).await?;\n    tracing::info!(\"del user session: {} res: {}\", session_id, res);\n    Ok(())\n  }\n\n  pub async fn put_code_session(\n    &self,\n    code: &str,\n    code_session: &CodeSession,\n  ) -> redis::RedisResult<()> {\n    let key = code_session_key(code);\n    self\n      .redis_client\n      .clone()\n      .set_options(\n        key,\n        code_session,\n        redis::SetOptions::default().with_expiration(redis::SetExpiry::EX(60 * 5)), // code is valid for 5 minutes\n      )\n      .await\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct CodeSession {\n  pub session_id: String,\n  pub code_challenge: Option<String>,\n  pub code_challenge_method: Option<String>,\n}\nstruct CodeSessionOptional(Option<CodeSession>);\n\nimpl ToRedisArgs for CodeSession {\n  fn write_redis_args<W>(&self, out: &mut W)\n  where\n    W: ?Sized + redis::RedisWrite,\n  {\n    let s = serde_json::to_string(self).unwrap();\n    out.write_arg(s.as_bytes());\n  }\n}\n\nimpl FromRedisValue for CodeSessionOptional {\n  fn from_redis_value(v: &redis::Value) -> redis::RedisResult<Self> {\n    let bytes = expect_redis_value_data(v)?;\n    match bytes {\n      Some(bytes) => {\n        let session = expect_redis_json_bytes(bytes)?;\n        Ok(CodeSessionOptional(Some(session)))\n      },\n      None => Ok(CodeSessionOptional(None)),\n    }\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserSession {\n  pub session_id: String,\n  pub token: GotrueTokenResponse,\n}\nstruct UserSessionOptional(Option<UserSession>);\n\n#[async_trait]\nimpl FromRequestParts<AppState> for UserSession {\n  type Rejection = axum::response::Response;\n\n  async fn from_request_parts(\n    parts: &mut Parts,\n    state: &AppState,\n  ) -> Result<Self, Self::Rejection> {\n    let jar = match CookieJar::from_request_parts(parts, state).await {\n      Ok(jar) => jar,\n      Err(err) => {\n        tracing::error!(\"failed to get cookie jar, error: {}\", err);\n        let redirect_url = state.prepend_with_path_prefix(\"/web/login\");\n        return Err(Redirect::to(&redirect_url).into_response());\n      },\n    };\n\n    if let Some(session) =\n      get_session_from_store(&jar, &state.session_store, &state.gotrue_client).await\n    {\n      return Ok(session);\n    }\n\n    let original_url = parts\n      .extensions\n      .get::<OriginalUri>()\n      .map(|uri| urlencoding::encode(&uri.to_string()).to_string());\n\n    match original_url {\n      Some(url) => {\n        let redirect_url =\n          state.prepend_with_path_prefix(&format!(\"/web/login-v2?redirect_to={}\", url));\n        Err(Redirect::to(&redirect_url).into_response())\n      },\n      None => {\n        let redirect_url = state.prepend_with_path_prefix(\"/web/login\");\n        Err(Redirect::to(&redirect_url).into_response())\n      },\n    }\n  }\n}\n\nasync fn get_session_from_store(\n  cookie_jar: &CookieJar,\n  session_store: &SessionStorage,\n  gotrue_client: &gotrue::api::Client,\n) -> Option<UserSession> {\n  let session_id = match cookie_jar.get(\"session_id\") {\n    Some(cookie) => cookie.value(),\n    None => {\n      tracing::info!(\"no session_id cookie found\");\n      return None;\n    },\n  };\n\n  let mut session = session_store\n    .get_user_session(session_id)\n    .await\n    .unwrap_or_else(|err| {\n      tracing::error!(\"failed to get session from store: {}\", err);\n      None\n    })?;\n\n  if has_expired(session.token.access_token.as_str()) {\n    // Get new pair of access token and refresh token\n    let refresh_token = session.token.refresh_token;\n    let new_token = match gotrue_client\n      .clone()\n      .token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token }))\n      .await\n    {\n      Ok(token) => token,\n      Err(err) => {\n        tracing::warn!(\"failed to refresh token: {}\", err);\n        return None;\n      },\n    };\n\n    session.token.access_token = new_token.access_token;\n    session.token.refresh_token = new_token.refresh_token;\n\n    // Update session in redis\n    session_store\n      .put_user_session(&session)\n      .await\n      .unwrap_or_else(|err| tracing::error!(\"failed to update session: {}\", err));\n  }\n\n  Some(session)\n}\n\nfn has_expired(access_token: &str) -> bool {\n  match get_session_expiration(access_token) {\n    Some(expiration_seconds) => {\n      let now = SystemTime::now()\n        .duration_since(UNIX_EPOCH)\n        .expect(\"Time went backwards\")\n        .as_secs();\n      now > expiration_seconds\n    },\n    None => false,\n  }\n}\n\nfn get_session_expiration(access_token: &str) -> Option<u64> {\n  // no need to verify, let the appflowy cloud server do it\n  // in that way, frontend server does not need to know the secret\n  match jwt::Token::<Header, Claims, _>::parse_unverified(access_token) {\n    Ok(unverified) => unverified.claims().registered.expiration,\n    Err(e) => {\n      tracing::error!(\"failed to parse unverified token: {}\", e);\n      None\n    },\n  }\n}\n\nimpl ToRedisArgs for UserSession {\n  fn write_redis_args<W>(&self, out: &mut W)\n  where\n    W: ?Sized + redis::RedisWrite,\n  {\n    let s = serde_json::to_string(self).unwrap();\n    out.write_arg(s.as_bytes());\n  }\n}\n\nimpl FromRedisValue for UserSessionOptional {\n  fn from_redis_value(v: &redis::Value) -> redis::RedisResult<Self> {\n    let bytes = expect_redis_value_data(v)?;\n    match bytes {\n      Some(bytes) => {\n        let session = expect_redis_json_bytes(bytes)?;\n        Ok(UserSessionOptional(Some(session)))\n      },\n      None => Ok(UserSessionOptional(None)),\n    }\n  }\n}\n\nfn expect_redis_json_bytes<T>(v: &[u8]) -> redis::RedisResult<T>\nwhere\n  T: DeserializeOwned,\n{\n  let res: Result<T, serde_json::Error> = serde_json::from_slice(v);\n  match res {\n    Ok(v) => Ok(v),\n    Err(e) => Err(redis::RedisError::from((\n      redis::ErrorKind::TypeError,\n      \"redis data json deserialization failed!\",\n      e.to_string(),\n    ))),\n  }\n}\n\nfn expect_redis_value_data(v: &redis::Value) -> redis::RedisResult<Option<&[u8]>> {\n  match v {\n    redis::Value::Data(ref bytes) => Ok(Some(bytes)),\n    redis::Value::Nil => Ok(None),\n    x => Err(redis::RedisError::from((\n      redis::ErrorKind::TypeError,\n      \"unexpected value from redis\",\n      format!(\"redis value is not data: {:?}\", x),\n    ))),\n  }\n}\n\npub fn new_session_cookie(id: uuid::Uuid) -> Cookie<'static> {\n  let mut cookie = Cookie::new(\"session_id\", id.to_string());\n  cookie.set_path(\"/\");\n  cookie\n}\n"
  },
  {
    "path": "admin_frontend/src/templates.rs",
    "content": "use askama::Template;\nuse database_entity::dto::{AFWorkspace, AFWorkspaceInvitation};\nuse gotrue_entity::{dto::User, sso::SSOProvider};\n\nuse crate::{askama_entities::WorkspaceWithMembers, ext::entities::WorkspaceUsageLimits};\n\n#[derive(Template)]\n#[template(path = \"pages/redirect.html\")]\npub struct Redirect {\n  pub redirect_url: String,\n}\n\n#[derive(Template)]\n#[template(path = \"pages/open_appflowy_or_download.html\")]\npub struct OpenAppFlowyOrDownload {}\n\n#[derive(Template)]\n#[template(path = \"pages/login_callback.html\")]\npub struct LoginCallback {}\n\n#[derive(Template)]\n#[template(path = \"pages/payment_success_redirect.html\")]\npub struct PaymentSuccessRedirect {}\n\n#[derive(Template)]\n#[template(path = \"components/user_usage.html\")]\npub struct UserUsage {\n  pub workspace_count: usize,\n  pub workspace_limit: String,\n}\n\n// ./../templates/components/workspace_usage.html\n#[derive(Template)]\n#[template(path = \"components/workspace_usage.html\")]\npub struct WorkspaceUsageList {\n  pub workspace_usages: Vec<WorkspaceUsageLimits>,\n}\n\n#[derive(Template)]\n#[template(path = \"components/admin_sso_detail.html\")]\npub struct SsoDetail {\n  pub sso_provider: SSOProvider,\n  pub mapping_json: String,\n}\n\n#[derive(Template)]\n#[template(path = \"components/admin_sso_create.html\")]\npub struct SsoCreate;\n\n#[derive(Template)]\n#[template(path = \"components/admin_sso_list.html\")]\npub struct SsoList {\n  pub sso_providers: Vec<SSOProvider>,\n}\n\n#[derive(Template)]\n#[template(path = \"components/change_password.html\")]\npub struct ChangePassword;\n\n#[derive(Template)]\n#[template(path = \"pages/login.html\")]\npub struct Login<'a> {\n  pub path_prefix: &'a str,\n  pub oauth_providers: &'a [&'a str],\n  pub redirect_to: Option<&'a str>,\n  pub oauth_redirect_to: &'a str,\n}\n\n#[derive(Template)]\n#[template(path = \"pages/login_v2.html\")]\npub struct LoginV2<'a> {\n  pub oauth_providers: &'a [&'a str],\n  pub redirect_to: Option<&'a str>,\n  pub oauth_redirect_to: &'a str,\n  pub path_prefix: &'a str,\n}\n\n#[derive(Template)]\n#[template(path = \"pages/home.html\")]\npub struct Home<'a> {\n  pub user: &'a User,\n  pub is_admin: bool,\n  pub path_prefix: &'a str,\n}\n\n#[derive(Template)]\n#[template(path = \"components/create_user.html\")]\npub struct CreateUser;\n\n#[derive(Template)]\n#[template(path = \"components/invite.html\")]\npub struct Invite {\n  pub shared_workspaces: Vec<AFWorkspace>,\n  pub owned_workspaces: Vec<WorkspaceWithMembers>,\n  pub pending_workspace_invitations: Vec<AFWorkspaceInvitation>,\n}\n\n#[derive(Template)]\n#[template(path = \"components/shared_workspaces.html\")]\npub struct SharedWorkspaces {\n  pub shared_workspaces: Vec<AFWorkspace>,\n}\n\n#[derive(Template)]\n#[template(path = \"components/admin_navigate.html\")]\npub struct AdminNavigate;\n\n#[derive(Template)]\n#[template(path = \"components/navigate.html\")]\npub struct Navigate;\n\n#[derive(Template)]\n#[template(path = \"pages/admin_home.html\")]\npub struct AdminHome<'a> {\n  pub path_prefix: &'a str,\n  pub user: &'a User,\n}\n\n#[derive(Template)]\n#[template(path = \"components/admin_users.html\")]\npub struct AdminUsers<'a> {\n  pub users: &'a [gotrue_entity::dto::User],\n}\n\n#[derive(Template)]\n#[template(path = \"components/user_details.html\")]\npub struct UserDetails<'a> {\n  pub user: &'a gotrue_entity::dto::User,\n}\n\n#[derive(Template)]\n#[template(path = \"components/admin_user_details.html\")]\npub struct AdminUserDetails<'a> {\n  pub user: &'a gotrue_entity::dto::User,\n}\n\n// Any filter defined in the module `filters` is accessible in your template.\nmod filters {\n  pub fn default<T: std::fmt::Display>(\n    input: &Option<T>,\n    default_val: &str,\n  ) -> ::askama::Result<String> {\n    Ok(\n      input\n        .as_ref()\n        .map(|i| i.to_string())\n        .unwrap_or_else(|| default_val.to_string()),\n    )\n  }\n}\n"
  },
  {
    "path": "admin_frontend/src/web_api.rs",
    "content": "use crate::error::WebApiError;\nuse crate::ext::api::{\n  accept_workspace_invitation, delete_current_user, invite_user_to_workspace, leave_workspace,\n  verify_token_cloud,\n};\nuse crate::models::{AppState, WebApiLoginRequest};\nuse crate::models::{\n  LoginParams, OAuthRedirect, OAuthRedirectToken, WebApiAdminCreateUserRequest,\n  WebApiChangePasswordRequest, WebApiCreateSSOProviderRequest, WebApiInviteUserRequest,\n  WebApiPutUserRequest,\n};\nuse crate::response::WebApiResponse;\nuse crate::session::{self, new_session_cookie, CodeSession, UserSession};\nuse axum::extract::{Path, Query};\nuse axum::http::{status, HeaderMap, StatusCode};\nuse axum::response::{IntoResponse, Redirect, Result};\nuse axum::routing::{delete, get};\nuse axum::Form;\nuse axum::{extract::State, routing::post, Router};\nuse axum_extra::extract::cookie::Cookie;\nuse axum_extra::extract::CookieJar;\nuse base64::engine::Engine;\nuse base64::prelude::BASE64_STANDARD_NO_PAD;\nuse gotrue::params::{\n  AdminDeleteUserParams, AdminUserParams, CreateSSOProviderParams, GenerateLinkParams,\n  MagicLinkParams,\n};\nuse gotrue_entity::dto::{GotrueTokenResponse, SignUpResponse, UpdateGotrueUserParams, User};\nuse rand::distributions::Alphanumeric;\nuse rand::Rng;\nuse sha2::Digest;\nuse tracing::info;\n\npub fn router() -> Router<AppState> {\n  Router::new()\n    .route(\"/signin\", post(sign_in_handler))\n    .route(\"/oauth-redirect\", get(oauth_redirect_handler))\n    .route(\"/oauth-redirect/token\", get(oauth_redirect_token_handler))\n    .route(\"/signup\", post(sign_up_handler))\n    .route(\"/login-refresh/:refresh_token\", post(login_refresh_handler))\n    .route(\"/logout\", post(logout_handler))\n\n    // user\n    .route(\"/change-password\", post(change_password_handler))\n    .route(\"/oauth_login/:provider\", post(post_oauth_login_handler))\n    .route(\"/invite\", post(invite_handler))\n    .route(\"/workspace/:workspace_id/invite\", post(workspace_invite_handler))\n    .route(\"/workspace/:workspace_id/leave\", post(leave_workspace_handler))\n    .route(\"/invite/:invite_id/accept\", post(invite_accept_handler))\n    .route(\"/open_app\", post(open_app_handler))\n    .route(\"/delete-account\", delete(delete_account_handler))\n\n    // admin\n    .route(\"/admin/user\", post(admin_add_user_handler))\n    .route(\n      \"/admin/user/:user_uuid\",\n      delete(admin_delete_user_handler).put(admin_update_user_handler),\n    )\n    .route(\n      \"/admin/user/:email/generate-link\",\n      post(post_user_generate_link_handler),\n    )\n    .route(\"/admin/sso\", post(admin_create_sso_handler))\n    .route(\"/admin/sso/:provider_id\", delete(admin_delete_sso_handler))\n}\n\nasync fn admin_delete_sso_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(provider_id): Path<String>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  let _ = state\n    .gotrue_client\n    .admin_delete_sso_provider(&session.token.access_token, &provider_id)\n    .await?;\n\n  Ok(WebApiResponse::<()>::from_str(\"SSO Deleted\".into()))\n}\n\nasync fn admin_create_sso_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Form(param): Form<WebApiCreateSSOProviderRequest>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  let provider_params = CreateSSOProviderParams {\n    type_: param.type_,\n    metadata_url: param.metadata_url,\n    ..Default::default()\n  };\n\n  let _ = state\n    .gotrue_client\n    .admin_create_sso_providers(&session.token.access_token, &provider_params)\n    .await?;\n\n  Ok(WebApiResponse::<()>::from_str(\"SSO Added\".into()))\n}\n\n/// Generates a URL to facilitate login redirection to the AppFlowy app from a web browser.\n///\n/// This function creates a custom URL scheme that can be used in a web browser to open the\n/// AppFlowy app and automatically handle user login based on the provided `UserSession`.\n///\n/// # Returns\n/// A `Result` containing `HeaderMap` for HTTP redirection if successful, or `WebApiError` in case of failure.\n///\n/// # Example URL Format\n/// `appflowy-flutter://login-callback#access_token=...&expires_at=...&expires_in=...&refresh_token=...&token_type=...`\n///\n/// The URL includes access token information and other relevant session details.\n///\n/// # Usage\n/// The client application should implement handling for this URL format, typically through the\n/// `sign_in_with_url` method in the `client-api` crate. See [client_api::Client::sign_in_with_url] for more details.\n///\nasync fn open_app_handler(\n  session: UserSession,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  let app_sign_in_url = format!(\n      \"appflowy-flutter://login-callback#access_token={}&expires_at={}&expires_in={}&refresh_token={}&token_type={}\",\n        session.token.access_token,\n        session.token.expires_at,\n        session.token.expires_in,\n        session.token.refresh_token,\n        session.token.token_type,\n  );\n  Ok(htmx_redirect(&app_sign_in_url).into_response())\n}\n\n/// Delete the user account and all associated data.\nasync fn delete_account_handler(\n  state: State<AppState>,\n  session: UserSession,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  delete_current_user(&session.token.access_token, &state.appflowy_cloud_url).await?;\n  let redirect_url = state.prepend_with_path_prefix(\"/web/login\");\n  Ok(Redirect::to(&redirect_url).into_response())\n}\n\n// Invite another user, this will trigger email sending\n// to the target user\nasync fn invite_handler(\n  State(state): State<AppState>,\n  Form(param): Form<WebApiInviteUserRequest>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  let magic_link_redirect = if state.config.path_prefix.is_empty() {\n    \"/\".to_owned()\n  } else {\n    state.config.path_prefix.clone()\n  };\n  state\n    .gotrue_client\n    .magic_link(\n      &MagicLinkParams {\n        email: param.email,\n        ..Default::default()\n      },\n      Some(magic_link_redirect),\n    )\n    .await?;\n  Ok(WebApiResponse::<()>::from_str(\"Invitation sent\".into()))\n}\n\nasync fn workspace_invite_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(workspace_id): Path<String>,\n  Form(param): Form<WebApiInviteUserRequest>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  invite_user_to_workspace(\n    &session.token.access_token,\n    &workspace_id,\n    &param.email,\n    &state.appflowy_cloud_url,\n  )\n  .await?;\n\n  Ok(WebApiResponse::<()>::from_str(\"Invitation sent\".into()))\n}\n\nasync fn leave_workspace_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(workspace_id): Path<String>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  leave_workspace(\n    &session.token.access_token,\n    &workspace_id,\n    &state.appflowy_cloud_url,\n  )\n  .await?;\n\n  Ok(WebApiResponse::<()>::from_str(\"Left workspace\".into()))\n}\n\nasync fn invite_accept_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(invite_id): Path<String>,\n) -> Result<HeaderMap, WebApiError<'static>> {\n  accept_workspace_invitation(\n    &session.token.access_token,\n    &invite_id,\n    &state.appflowy_cloud_url,\n  )\n  .await?;\n\n  Ok(htmx_trigger(\"workspaceInvitationAccepted\"))\n}\n\nasync fn change_password_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Form(param): Form<WebApiChangePasswordRequest>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  if param.new_password != param.confirm_password {\n    return Err(WebApiError::new(\n      status::StatusCode::BAD_REQUEST,\n      \"passwords do not match\",\n    ));\n  }\n  let _user = state\n    .gotrue_client\n    .update_user(\n      &session.token.access_token,\n      &UpdateGotrueUserParams {\n        password: Some(param.new_password),\n        ..Default::default()\n      },\n    )\n    .await?;\n  Ok(WebApiResponse::<()>::from_str(\"Password changed\".into()))\n}\n\nasync fn post_oauth_login_handler(\n  header_map: HeaderMap,\n  Path(provider): Path<String>,\n) -> Result<WebApiResponse<String>, WebApiError<'static>> {\n  let base_url = get_base_url(&header_map);\n  let redirect_uri = format!(\"{}/web/oauth_login_redirect\", base_url);\n\n  let oauth_url = format!(\n    \"{}/authorize?provider={}&redirect_uri={}\",\n    base_url, &provider, redirect_uri\n  );\n  Ok(oauth_url.into())\n}\n\nasync fn admin_update_user_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(user_uuid): Path<String>,\n  Form(param): Form<WebApiPutUserRequest>,\n) -> Result<WebApiResponse<User>, WebApiError<'static>> {\n  let res = state\n    .gotrue_client\n    .admin_update_user(\n      &session.token.access_token,\n      &user_uuid,\n      &AdminUserParams {\n        password: Some(param.password.to_owned()),\n        email_confirm: true,\n        ..Default::default()\n      },\n    )\n    .await?;\n  Ok(res.into())\n}\n\nasync fn post_user_generate_link_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(email): Path<String>,\n) -> Result<String, WebApiError<'static>> {\n  let res = state\n    .gotrue_client\n    .admin_generate_link(\n      &session.token.access_token,\n      &GenerateLinkParams {\n        email,\n        ..Default::default()\n      },\n    )\n    .await?;\n  Ok(res.action_link)\n}\n\nasync fn admin_delete_user_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(user_uuid): Path<String>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  state\n    .gotrue_client\n    .admin_delete_user(\n      &session.token.access_token,\n      &user_uuid,\n      &AdminDeleteUserParams {\n        should_soft_delete: false,\n      },\n    )\n    .await?;\n  Ok(().into())\n}\n\nasync fn admin_add_user_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Form(param): Form<WebApiAdminCreateUserRequest>,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  let add_user_params = AdminUserParams {\n    email: param.email,\n    password: Some(param.password),\n    email_confirm: !param.require_email_verification,\n    ..Default::default()\n  };\n  let _user = state\n    .gotrue_client\n    .admin_add_user(&session.token.access_token, &add_user_params)\n    .await?;\n  Ok(WebApiResponse::<()>::from_str(\"User created\".into()))\n}\n\nasync fn login_refresh_handler(\n  State(state): State<AppState>,\n  jar: CookieJar,\n  Path(refresh_token): Path<String>,\n  Query(login): Query<LoginParams>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  let token = state\n    .gotrue_client\n    .token(&gotrue::grant::Grant::RefreshToken(\n      gotrue::grant::RefreshTokenGrant { refresh_token },\n    ))\n    .await?;\n\n  // Do another round of refresh_token to consume and invalidate the old one\n  let token = state\n    .gotrue_client\n    .token(&gotrue::grant::Grant::RefreshToken(\n      gotrue::grant::RefreshTokenGrant {\n        refresh_token: token.refresh_token,\n      },\n    ))\n    .await?;\n\n  session_login(State(state), token, jar, login.redirect_to.as_deref()).await\n}\n\n// login and set the cookie\n// sign up if not exist\nasync fn sign_in_handler(\n  State(state): State<AppState>,\n  jar: CookieJar,\n  Form(param): Form<WebApiLoginRequest>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  let WebApiLoginRequest {\n    email,\n    password,\n    redirect_to,\n  } = param;\n\n  if password.is_empty() {\n    let res = send_magic_link(State(state), &email).await?;\n    return Ok(res.into_response());\n  }\n\n  // Attempt to sign in with email and password\n  let token = state\n    .gotrue_client\n    .token(&gotrue::grant::Grant::Password(\n      gotrue::grant::PasswordGrant {\n        email: email.to_owned(),\n        password: password.to_owned(),\n      },\n    ))\n    .await?;\n\n  session_login(State(state), token, jar, redirect_to.as_deref()).await\n}\n\nasync fn oauth_redirect_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Query(oauth_redirect): Query<OAuthRedirect>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  {\n    // OAuthRedirect verification\n    if oauth_redirect.client_id != state.config.oauth.client_id {\n      return Err(WebApiError::new(\n        StatusCode::BAD_REQUEST,\n        \"invalid client_id\",\n      ));\n    }\n    if oauth_redirect.response_type != \"code\" {\n      return Err(WebApiError::new(\n        StatusCode::BAD_REQUEST,\n        \"invalid response_type, only 'code' is support\",\n      ));\n    }\n    {\n      // Check if the redirect_uri is in the allowable list\n      let mut found = false;\n      for allowable_uri in &state.config.oauth.allowable_redirect_uris {\n        if oauth_redirect.redirect_uri == *allowable_uri {\n          found = true;\n          break;\n        }\n      }\n      if !found {\n        return Err(WebApiError::new(\n          StatusCode::BAD_REQUEST,\n          format!(\n            \"invalid redirect_uri: {}, allowable_uris: {}\",\n            oauth_redirect.redirect_uri,\n            state.config.oauth.allowable_redirect_uris.join(\", \")\n          ),\n        ));\n      }\n    }\n  }\n\n  let code = gen_rand_alpha_num(32);\n  state\n    .session_store\n    .put_code_session(\n      &code,\n      &CodeSession {\n        session_id: session.session_id.clone(),\n        code_challenge: oauth_redirect.code_challenge,\n        code_challenge_method: oauth_redirect.code_challenge_method,\n      },\n    )\n    .await?;\n\n  let url = format!(\n    \"{}?code={}&state={}\",\n    oauth_redirect.redirect_uri, code, oauth_redirect.state,\n  );\n  let resp = Redirect::to(&url).into_response();\n  Ok(resp)\n}\n\nasync fn oauth_redirect_token_handler(\n  State(state): State<AppState>,\n  Query(token_req): Query<OAuthRedirectToken>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  // Check client secret (if exists)\n  if let Some(server_client_secret) = state.config.oauth.client_secret {\n    match token_req.client_secret {\n      Some(given_client_secret) => {\n        if server_client_secret != given_client_secret {\n          return Err(WebApiError::new(\n            StatusCode::BAD_REQUEST,\n            \"invalid client_secret\",\n          ));\n        }\n      },\n      _ => {\n        return Err(WebApiError::new(\n          StatusCode::BAD_REQUEST,\n          \"expecting client_secret\",\n        ));\n      },\n    }\n  };\n\n  let code_session = state\n    .session_store\n    .get_code_session(&token_req.code)\n    .await?\n    .ok_or_else(|| WebApiError::new(StatusCode::BAD_REQUEST, \"invalid code\"))?;\n\n  if let Some(code_challenge) = code_session.code_challenge {\n    match code_session.code_challenge_method.as_deref() {\n      Some(\"S256\") => {\n        let verifier = token_req.code_verifier.ok_or_else(|| {\n          WebApiError::new(status::StatusCode::BAD_REQUEST, \"missing code_verifier\")\n        })?;\n\n        // get code challenge based64 decoded\n        let code_challenge = BASE64_STANDARD_NO_PAD\n          .decode(code_challenge)\n          .map_err(|err| {\n            WebApiError::new(\n              status::StatusCode::BAD_REQUEST,\n              format!(\"failed to base64 decode code challege: {}\", err),\n            )\n          })?;\n\n        // hash the verifier and check against the original code challenge\n        let mut hasher = sha2::Sha256::new();\n        hasher.update(verifier.as_bytes());\n        let verifier_hashed = hasher.finalize().to_vec();\n        if verifier_hashed != code_challenge {\n          return Err(WebApiError::new(\n            status::StatusCode::BAD_REQUEST,\n            \"invalid code_verifier\",\n          ));\n        }\n      },\n      _ => {\n        return Err(WebApiError::new(\n          status::StatusCode::BAD_REQUEST,\n          \"invalid code_challenge_method, only support S256\",\n        ));\n      },\n    }\n  }\n\n  let user_session = state\n    .session_store\n    .get_user_session(&code_session.session_id)\n    .await?\n    .ok_or_else(|| WebApiError::new(StatusCode::BAD_REQUEST, \"invalid session\"))?;\n\n  let resp = axum::Json::from(user_session.token);\n  Ok(resp.into_response())\n}\n\nasync fn sign_up_handler(\n  State(state): State<AppState>,\n  jar: CookieJar,\n  Form(param): Form<WebApiLoginRequest>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  let WebApiLoginRequest {\n    email,\n    password,\n    redirect_to,\n  } = param;\n\n  if password.is_empty() {\n    let res = send_magic_link(State(state), &email).await?;\n    return Ok(res.into_response());\n  }\n\n  let sign_up_res = state\n    .gotrue_client\n    .sign_up(&email, &password, Some(\"/\"))\n    .await?;\n\n  match sign_up_res {\n    // when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in\n    SignUpResponse::Authenticated(token) => {\n      session_login(State(state), token, jar, redirect_to.as_deref()).await\n    },\n    SignUpResponse::NotAuthenticated(user) => {\n      info!(\"user signed up and not authenticated: {:?}\", user);\n      Ok(WebApiResponse::<()>::from_str(\"Email Verification Sent\".into()).into_response())\n    },\n  }\n}\n\nasync fn logout_handler(\n  State(state): State<AppState>,\n  jar: CookieJar,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  let session_id = jar\n    .get(\"session_id\")\n    .ok_or(WebApiError::new(\n      status::StatusCode::BAD_REQUEST,\n      \"no session_id cookie\",\n    ))?\n    .value();\n\n  state.session_store.del_user_session(session_id).await?;\n  let htmx_redirect_url = format!(\"{}/web/login\", state.config.path_prefix);\n  Ok(\n    (\n      jar.remove(Cookie::from(\"session_id\")),\n      htmx_redirect(&htmx_redirect_url),\n    )\n      .into_response(),\n  )\n}\n\nfn htmx_trigger(trigger: &str) -> HeaderMap {\n  let mut h = HeaderMap::new();\n  h.insert(\"HX-Trigger\", trigger.parse().unwrap());\n  h\n}\n\nasync fn session_login(\n  State(state): State<AppState>,\n  token: GotrueTokenResponse,\n  jar: CookieJar,\n  redirect_to: Option<&str>,\n) -> Result<axum::response::Response, WebApiError<'static>> {\n  verify_token_cloud(\n    token.access_token.as_str(),\n    state.appflowy_cloud_url.as_str(),\n  )\n  .await?;\n\n  let new_session_id = uuid::Uuid::new_v4();\n  let new_session = session::UserSession {\n    session_id: new_session_id.to_string(),\n    token,\n  };\n  state.session_store.put_user_session(&new_session).await?;\n\n  let decoded_redirect_to = redirect_to.and_then(|s| match urlencoding::decode(s) {\n    Ok(r) => Some(r),\n    Err(err) => {\n      tracing::error!(\"failed to decode redirect_to: {}\", err);\n      None\n    },\n  });\n  let default_htmx_redirect_url = format!(\"{}/web/home\", state.config.path_prefix);\n  Ok(\n    (\n      jar.add(new_session_cookie(new_session_id)),\n      htmx_redirect(\n        decoded_redirect_to\n          .as_deref()\n          .unwrap_or(&default_htmx_redirect_url),\n      ),\n    )\n      .into_response(),\n  )\n}\n\nasync fn send_magic_link(\n  State(state): State<AppState>,\n  email: &str,\n) -> Result<WebApiResponse<()>, WebApiError<'static>> {\n  state\n    .gotrue_client\n    .magic_link(\n      &MagicLinkParams {\n        email: email.to_owned(),\n        ..Default::default()\n      },\n      Some(format!(\"{}/web/login-callback\", state.config.path_prefix)),\n    )\n    .await?;\n  Ok(WebApiResponse::<()>::from_str(\"Magic Link Sent\".into()))\n}\n\nfn htmx_redirect(url: &str) -> HeaderMap {\n  let mut h = HeaderMap::new();\n  h.insert(\"Location\", url.parse().unwrap());\n  h.insert(\"HX-Redirect\", url.parse().unwrap());\n  h\n}\n\nfn get_base_url(header_map: &HeaderMap) -> String {\n  let scheme = get_header_value_or_default(header_map, \"x-scheme\", \"http\");\n  let host = get_header_value_or_default(header_map, \"host\", \"localhost\");\n\n  format!(\"{}://{}\", scheme, host)\n}\n\nfn get_header_value_or_default<'a>(\n  header_map: &'a HeaderMap,\n  header_name: &str,\n  default: &'a str,\n) -> &'a str {\n  match header_map.get(header_name) {\n    Some(v) => match v.to_str() {\n      Ok(v) => v,\n      Err(e) => {\n        tracing::error!(\"failed to get header value {}: {}, {:?}\", header_name, e, v);\n        default\n      },\n    },\n    None => default,\n  }\n}\n\nfn gen_rand_alpha_num(n: usize) -> String {\n  let random_string: String = rand::thread_rng()\n    .sample_iter(&Alphanumeric)\n    .take(n)\n    .map(char::from)\n    .collect();\n  random_string\n}\n"
  },
  {
    "path": "admin_frontend/src/web_app.rs",
    "content": "use crate::askama_entities::WorkspaceWithMembers;\nuse crate::error::WebAppError;\nuse crate::ext::api::{\n  accept_workspace_invitation, get_accepted_workspace_invitations,\n  get_pending_workspace_invitations, get_user_owned_workspaces, get_user_profile,\n  get_user_workspace_limit, get_user_workspace_usages, get_user_workspaces, get_workspace_members,\n  verify_token_cloud,\n};\nuse crate::models::{LoginParams, OAuthLoginAction, WebAppOAuthLoginRequest};\nuse crate::session::{self, new_session_cookie, UserSession};\nuse askama::Template;\nuse axum::extract::{Path, Query, State};\nuse axum::response::{IntoResponse, Redirect, Result};\nuse axum::{response::Html, routing::get, Router};\nuse axum_extra::extract::CookieJar;\nuse gotrue_entity::dto::User;\n\nuse crate::{templates, AppState};\n\nstatic DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX: &str = \"/web/login-callback\";\n\npub fn router(state: AppState) -> Router<AppState> {\n  Router::new()\n    .nest_service(\"/\", page_router().with_state(state.clone()))\n    .nest_service(\"/components\", component_router().with_state(state))\n}\n\nfn page_router() -> Router<AppState> {\n  Router::new()\n    .route(\"/\", get(home_handler))\n    .route(\"/login\", get(login_handler))\n    .route(\"/login-v2\", get(login_v2_handler))\n    .route(\"/login-callback\", get(login_callback_handler))\n    .route(\"/payment-success\", get(payment_success_handler))\n    .route(\"/login-callback-query\", get(login_callback_query_handler))\n    .route(\n      \"/open-appflowy-or-download\",\n      get(open_appflowy_or_download_handler),\n    )\n    .route(\"/home\", get(home_handler))\n    .route(\"/admin/home\", get(admin_home_handler))\n}\n\nfn component_router() -> Router<AppState> {\n  Router::new()\n    // User actions\n    .route(\"/user/navigate\", get(user_navigate_handler))\n    .route(\"/user/user\", get(user_user_handler))\n    .route(\"/user/change-password\", get(user_change_password_handler))\n    .route(\"/user/invite\", get(user_invite_handler))\n    .route(\"/user/shared-workspaces\", get(shared_workspaces_handler))\n    .route(\"/user/user-usage\", get(user_usage_handler))\n    .route(\"/user/workspace-usage\", get(workspace_usage_handler))\n\n    // Admin actions\n    .route(\"/admin/navigate\", get(admin_navigate_handler))\n    .route(\"/admin/users\", get(admin_users_handler))\n    .route(\"/admin/users/:user_id\", get(admin_user_details_handler))\n    .route(\"/admin/users/create\", get(admin_users_create_handler))\n    // SSO\n    .route(\"/admin/sso\", get(admin_sso_handler))\n    .route(\"/admin/sso/create\", get(admin_sso_create_handler))\n    .route(\"/admin/sso/:sso_provider_id\", get(admin_sso_detail_handler))\n}\n\nasync fn open_appflowy_or_download_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::OpenAppFlowyOrDownload {})\n}\n\nasync fn login_callback_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::LoginCallback {})\n}\n\nasync fn payment_success_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::PaymentSuccessRedirect {})\n}\n\nasync fn login_callback_query_handler(\n  State(state): State<AppState>,\n  session: Option<UserSession>,\n  Query(query): Query<WebAppOAuthLoginRequest>,\n  mut jar: CookieJar,\n) -> Result<axum::response::Response, WebAppError> {\n  let refresh_token = {\n    match query.refresh_token {\n      Some(refresh_token) => refresh_token,\n      None => match session {\n        Some(session) => session.token.refresh_token,\n        None => match query.error {\n          Some(err) => {\n            tracing::error!(\n              \"OAuth login error: {:?}, code: {:?}, description: {:?}\",\n              err,\n              query.error_code,\n              query.error_description\n            );\n            let redirect_url = format!(\n                \"https://appflowy.io/invitation/expired?workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}\",\n                query.workspace_name.unwrap_or_default(),\n                query.workspace_icon.unwrap_or_default(),\n                query.user_name.unwrap_or_default(),\n                query.user_icon.unwrap_or_default(),\n                query.workspace_member_count.unwrap_or_default());\n\n            let expired_html = render_template(templates::Redirect { redirect_url })?;\n            return Ok(expired_html.into_response());\n          },\n          None => {\n            return Err(WebAppError::BadRequest(\n              \"refresh_token not found\".to_string(),\n            ));\n          },\n        },\n      },\n    }\n  };\n\n  let token = state\n    .gotrue_client\n    .token(&gotrue::grant::Grant::RefreshToken(\n      gotrue::grant::RefreshTokenGrant { refresh_token },\n    ))\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n\n  verify_token_cloud(\n    token.access_token.as_str(),\n    state.appflowy_cloud_url.as_str(),\n  )\n  .await?;\n\n  let new_session_id = uuid::Uuid::new_v4();\n  let new_session = session::UserSession {\n    session_id: new_session_id.to_string(),\n    token,\n  };\n  state.session_store.put_user_session(&new_session).await?;\n  jar = jar.add(new_session_cookie(new_session_id));\n\n  match query.action {\n    Some(action) => match action {\n      OAuthLoginAction::AcceptWorkspaceInvite => {\n        let invite_id = query\n          .workspace_invitation_id\n          .ok_or(WebAppError::BadRequest(\n            \"workspace_invitation_id not found\".to_string(),\n          ))?;\n\n        {\n          // If user has already accepted the invitation, redirect to open or download AppFlowy\n          let accepted_invitations = get_accepted_workspace_invitations(\n            &new_session.token.access_token,\n            &state.appflowy_cloud_url,\n          )\n          .await?;\n          let found = accepted_invitations\n            .iter()\n            .find(|w| w.invite_id.to_string() == invite_id);\n          if found.is_some() {\n            let open_or_dl_html = render_template(templates::OpenAppFlowyOrDownload {})?;\n            return Ok((jar, open_or_dl_html).into_response());\n          }\n        }\n\n        if let Err(err) = accept_workspace_invitation(\n          &new_session.token.access_token,\n          &invite_id,\n          &state.appflowy_cloud_url,\n        )\n        .await\n        {\n          tracing::error!(\"accepting workspace invitation: {:?}\", err);\n          let redirect_url = format!(\n            \"https://appflowy.io/invitation/expired?workspace_name={}&workspace_icon={}&user_name={}&user_icon={}&workspace_member_count={}\",\n            query.workspace_name.unwrap_or_default(),\n            query.workspace_icon.unwrap_or_default(),\n            query.user_name.unwrap_or_default(),\n            query.user_icon.unwrap_or_default(),\n            query.workspace_member_count.unwrap_or_default());\n          let redirect_html = render_template(templates::Redirect { redirect_url })?;\n          return Ok(redirect_html.into_response());\n        };\n        let open_or_dl_html = render_template(templates::OpenAppFlowyOrDownload {})?;\n        Ok((jar, open_or_dl_html).into_response())\n      },\n    },\n    None => match query.redirect_to {\n      Some(redirect_url) => match urlencoding::decode(&redirect_url).map(String::from) {\n        Ok(redirect_url) => {\n          let redirect_html = render_template(templates::Redirect { redirect_url })?;\n          Ok((jar, redirect_html).into_response())\n        },\n        Err(err) => {\n          tracing::error!(\"Error decoding redirect_url: {:?}\", err);\n          home_handler(State(state), Some(new_session), jar).await\n        },\n      },\n      None => home_handler(State(state), Some(new_session), jar).await,\n    },\n  }\n}\n\nasync fn admin_sso_detail_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(sso_provider_id): Path<String>,\n) -> Result<Html<String>, WebAppError> {\n  let sso_provider = state\n    .gotrue_client\n    .admin_get_sso_provider(&session.token.access_token, &sso_provider_id)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n\n  let mapping_json =\n    serde_json::to_string_pretty(&sso_provider.saml.attribute_mapping).unwrap_or(\"\".to_owned());\n\n  render_template(templates::SsoDetail {\n    sso_provider,\n    mapping_json,\n  })\n}\n\nasync fn admin_sso_create_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::SsoCreate)\n}\n\nasync fn admin_sso_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let sso_providers = state\n    .gotrue_client\n    .admin_list_sso_providers(&session.token.access_token)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?\n    .items\n    .unwrap_or_default();\n\n  render_template(templates::SsoList { sso_providers })\n}\n\nasync fn user_navigate_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::Navigate)\n}\n\nasync fn admin_navigate_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::AdminNavigate)\n}\n\nasync fn shared_workspaces_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let user_workspaces =\n    get_user_workspaces(&session.token.access_token, &state.appflowy_cloud_url).await?;\n\n  let profile = get_user_profile(\n    session.token.access_token.as_str(),\n    state.appflowy_cloud_url.as_str(),\n  )\n  .await?;\n\n  let shared_workspaces = user_workspaces\n    .into_iter()\n    .filter(|workspace| workspace.owner_uid != profile.uid)\n    .collect::<Vec<_>>();\n\n  render_template(templates::SharedWorkspaces { shared_workspaces })\n}\n\nasync fn user_invite_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let user_workspaces =\n    get_user_workspaces(&session.token.access_token, &state.appflowy_cloud_url).await?;\n\n  let profile = get_user_profile(\n    session.token.access_token.as_str(),\n    state.appflowy_cloud_url.as_str(),\n  )\n  .await?;\n\n  let mut shared_workspaces = Vec::new();\n  let mut owned_workspaces = Vec::with_capacity(user_workspaces.len());\n\n  for workspace in user_workspaces {\n    if workspace.owner_uid == profile.uid {\n      let members = get_workspace_members(\n        workspace.workspace_id.to_string().as_str(),\n        session.token.access_token.as_str(),\n        state.appflowy_cloud_url.as_str(),\n      )\n      .await?;\n      owned_workspaces.push(WorkspaceWithMembers { workspace, members });\n    } else {\n      shared_workspaces.push(workspace);\n    }\n  }\n\n  let pending_workspace_invitations = get_pending_workspace_invitations(\n    session.token.access_token.as_str(),\n    state.appflowy_cloud_url.as_str(),\n  )\n  .await?;\n\n  render_template(templates::Invite {\n    shared_workspaces,\n    owned_workspaces,\n    pending_workspace_invitations,\n  })\n}\n\nasync fn user_usage_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let workspace_count =\n    get_user_owned_workspaces(&session.token.access_token, &state.appflowy_cloud_url)\n      .await\n      .map(|workspaces| workspaces.len())\n      .unwrap_or_else(|err| {\n        tracing::error!(\"Error getting user workspace count: {:?}\", err);\n        0\n      });\n\n  let workspace_limit =\n    get_user_workspace_limit(&session.token.access_token, &state.appflowy_cloud_url)\n      .await\n      .map(|limit| limit.workspace_count.to_string())\n      .unwrap_or_else(|err| {\n        tracing::warn!(\"unable to get user workspace limit: {:?}\", err);\n        \"N/A\".to_owned()\n      });\n\n  render_template(templates::UserUsage {\n    workspace_count,\n    workspace_limit,\n  })\n}\n\nasync fn workspace_usage_handler(\n  State(app_state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let workspace_usages =\n    get_user_workspace_usages(&session.token.access_token, &app_state.appflowy_cloud_url).await?;\n  render_template(templates::WorkspaceUsageList { workspace_usages })\n}\n\nasync fn admin_users_create_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::CreateUser)\n}\n\nasync fn user_user_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let user = state\n    .gotrue_client\n    .user_info(&session.token.access_token)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n  render_template(templates::UserDetails { user: &user })\n}\n\nasync fn login_handler(\n  State(state): State<AppState>,\n  Query(login): Query<LoginParams>,\n) -> Result<Html<String>, WebAppError> {\n  let redirect_to = login\n    .redirect_to\n    .as_ref()\n    .map(|r| urlencoding::encode(r).to_string());\n  let oauth_redirect_to = login.redirect_to.as_ref().map(|r| {\n    urlencoding::encode(&format!(\n      \"{}/web/login-callback?redirect_to={}\",\n      state.config.path_prefix,\n      urlencoding::encode(r)\n    ))\n    .to_string()\n  });\n\n  let external = state\n    .gotrue_client\n    .settings()\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?\n    .external;\n  let oauth_providers = external.oauth_providers();\n  let default_oauth_redirect_to = format!(\n    \"{}{}\",\n    state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX\n  );\n  render_template(templates::Login {\n    path_prefix: &state.config.path_prefix,\n    oauth_providers: &oauth_providers,\n    redirect_to: redirect_to.as_deref(),\n    oauth_redirect_to: oauth_redirect_to\n      .as_deref()\n      .unwrap_or(&default_oauth_redirect_to),\n  })\n}\n\nasync fn login_v2_handler(\n  State(state): State<AppState>,\n  Query(login): Query<LoginParams>,\n) -> Result<Html<String>, WebAppError> {\n  let redirect_to = login\n    .redirect_to\n    .as_ref()\n    .map(|r| urlencoding::encode(r).to_string());\n  let oauth_redirect_to = login.redirect_to.as_ref().map(|r| {\n    urlencoding::encode(&format!(\n      \"{}/web/login-callback?redirect_to={}\",\n      state.config.path_prefix,\n      urlencoding::encode(r)\n    ))\n    .to_string()\n  });\n\n  let default_oauth_redirect_to = format!(\n    \"{}{}\",\n    state.config.path_prefix, DEFAULT_OAUTH_REDIRECT_TO_WITHOUT_PREFIX\n  );\n  render_template(templates::LoginV2 {\n    oauth_providers: &[\"Google\", \"Apple\", \"Github\", \"Discord\"],\n    redirect_to: redirect_to.as_deref(),\n    oauth_redirect_to: oauth_redirect_to\n      .as_deref()\n      .unwrap_or(&default_oauth_redirect_to),\n    path_prefix: &state.config.path_prefix,\n  })\n}\n\nasync fn user_change_password_handler() -> Result<Html<String>, WebAppError> {\n  render_template(templates::ChangePassword)\n}\n\npub async fn home_handler(\n  State(state): State<AppState>,\n  session: Option<UserSession>,\n  jar: CookieJar,\n) -> Result<axum::response::Response, WebAppError> {\n  let redirect_url = state.prepend_with_path_prefix(\"/web/login\");\n  let session = match session {\n    Some(session) => session,\n    None => return Ok(Redirect::to(&redirect_url).into_response()),\n  };\n\n  let user = state\n    .gotrue_client\n    .user_info(&session.token.access_token)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n  let home_html_str = render_template(templates::Home {\n    user: &user,\n    is_admin: is_admin(&user),\n    path_prefix: &state.config.path_prefix,\n  })?;\n  Ok((jar, home_html_str).into_response())\n}\n\nasync fn admin_home_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let user = state\n    .gotrue_client\n    .user_info(&session.token.access_token)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n  render_template(templates::AdminHome {\n    user: &user,\n    path_prefix: &state.config.path_prefix,\n  })\n}\n\nasync fn admin_users_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n) -> Result<Html<String>, WebAppError> {\n  let users = state\n    .gotrue_client\n    .admin_list_user(&session.token.access_token, None)\n    .await\n    .map_or_else(\n      |err| {\n        tracing::error!(\"Error getting user list: {:?}\", err);\n        vec![]\n      },\n      |r| r.users,\n    )\n    .into_iter()\n    .filter(|user| user.deleted_at.is_none())\n    .collect::<Vec<_>>();\n\n  render_template(templates::AdminUsers { users: &users })\n}\n\nasync fn admin_user_details_handler(\n  State(state): State<AppState>,\n  session: UserSession,\n  Path(user_id): Path<String>,\n) -> Result<Html<String>, WebAppError> {\n  let user = state\n    .gotrue_client\n    .admin_user_details(&session.token.access_token, &user_id)\n    .await\n    .map_err(|_| WebAppError::LoginRedirectRequired(state.config.path_prefix.clone()))?;\n\n  render_template(templates::AdminUserDetails { user: &user })\n}\n\nfn render_template<T>(x: T) -> Result<Html<String>, WebAppError>\nwhere\n  T: Template,\n{\n  let s = x.render()?;\n  Ok(Html(s))\n}\n\nfn is_admin(user: &User) -> bool {\n  user.role == \"supabase_admin\"\n}\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_navigate.html",
    "content": "<table style=\"border-collapse: collapse\">\n  <tr class=\"nav-item\">\n    <td>PgAdmin</td>\n    <td>\n      <a class=\"svg-container\" href=\"/pgadmin\">\n        {% include \"../assets/postgres/logo.html\" %}\n      </a>\n    </td>\n  </tr>\n\n  <tr class=\"nav-item\">\n    <td>Minio</td>\n    <td>\n      <a class=\"svg-container\" href=\"/minio\">\n        {% include \"../assets/minio/logo.html\" %}\n      </a>\n    </td>\n  </tr>\n</table>"
  },
  {
    "path": "admin_frontend/templates/components/admin_sidebar.html",
    "content": "<div class=\"sidebar\"></div>\n\n<div id=\"sidebar\">\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../../web/components/admin/navigate\"\n  >\n    Navigate\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../../web/components/admin/users\"\n  >\n    List Users\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../../web/components/admin/users/create\"\n  >\n    Create User\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../../web/components/admin/sso\"\n  >\n    List SSO\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../../web/components/admin/sso/create\"\n  >\n    Create SSO\n  </div>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_sso_create.html",
    "content": "<div>\n  <h4>Please enter the following information to create new SSO</h4>\n  <form hx-post=\"../../web-api/admin/sso\" hx-target=\"#none\">\n    <table>\n      <tr>\n        <td>Email</td>\n        <td>\n          <select name=\"type\" class=\"input\">\n            <option value=\"saml\">saml</option>\n          </select>\n        </td>\n      </tr>\n      <tr>\n        <td>Metadata Url</td>\n        <td>\n          <input\n            class=\"input\"\n            name=\"metadata_url\"\n            placeholder=\"https://example.com/metadata\"\n          />\n        </td>\n      </tr>\n      <tr>\n        <td></td>\n        <td style=\"text-align: right\">\n          <button class=\"button cyan\" type=\"submit\">Create</button>\n        </td>\n      </tr>\n    </table>\n  </form>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_sso_detail.html",
    "content": "<div>\n  <table>\n    <tr>\n      <td style=\"white-space: nowrap\">ID</td>\n      <td>{{ sso_provider.id|escape }}</td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap\">Entity ID</td>\n      <td>{{ sso_provider.saml.entity_id|escape }}</td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap\">Domains</td>\n      <td>\n        <ul>\n          {% for domain in sso_provider.domains %}\n          <li>{{ domain|escape }}</li>\n          {% endfor %}\n        </ul>\n      </td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap\">Created At</td>\n      <td>{{ sso_provider.created_at|escape }}</td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap\">Updated At</td>\n      <td>{{ sso_provider.updated_at|escape }}</td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap; align\">Metadata XML</td>\n      <td>\n        <code> {{ sso_provider.saml.metadata_xml|default(\"\")|escape }} </code>\n      </td>\n    </tr>\n    <tr>\n      <td style=\"white-space: nowrap\">Attribute Mapping</td>\n      <td>\n        <code> {{ mapping_json|escape }} </code>\n      </td>\n    </tr>\n  </table>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_sso_list.html",
    "content": "<div id=\"sso-list\">\n  <table>\n    <tr>\n      <th>Entity ID</th>\n      <th>Created At</th>\n      <th>Actions</th>\n    </tr>\n\n    {% for sso_provider in sso_providers %}\n    <tr>\n      <td>{{ sso_provider.saml.entity_id|escape }}</td>\n      <td>{{ sso_provider.created_at|escape }}</td>\n\n      <td>\n        <button\n          class=\"button cyan\"\n          hx-target=\"#sso-list\"\n          hx-get=\"../../web/components/admin/sso/{{ sso_provider.id|escape }}\"\n        >\n          More Info\n        </button>\n        <button\n          class=\"deleteUserBtn button red\"\n          hx-delete=\"../../web-api/admin/sso/{{ sso_provider.id|escape }}\"\n          hx-confirm=\"Are you sure?\"\n          hx-target=\"closest tr\"\n          hx-swap=\"delete\"\n        >\n          Delete\n        </button>\n      </td>\n    </tr>\n    {% endfor %}\n  </table>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_top_menu_bar.html",
    "content": "<div id=\"top-menu-bar\">\n  <div id=\"top-menu-bar-left\">\n    {% include \"../assets/logo.html\" %}\n    <h2>&nbsp; AppFlowy Cloud &nbsp;</h2>\n\n    <div\n      hx-target=\"#sidebar-content\"\n      hx-get=\"../../web/components/user/user\"\n      class=\"button red\"\n    >\n      {{ user.email|escape }}\n    </div>\n  </div>\n\n  <div id=\"top-menu-bar-right\">\n    <div id=\"adminBtn\" class=\"button cyan\">User</div>\n    <script>\n      document\n        .getElementById(\"adminBtn\")\n        .addEventListener(\"click\", function () {\n          window.location.href = \"{{ path_prefix }}/web/home\";\n        });\n    </script>\n\n    <div class=\"button yellow\" id=\"logoutBtn\" hx-post=\"../../web-api/logout\">\n      Logout\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_user_details.html",
    "content": "<div>\n  {% include \"user_details.html\" %}\n\n  <div>\n    <form\n      hx-put=\"../../web-api/admin/user/{{ user.id|escape }}\"\n      hx-target=\"#none\"\n    >\n      <table>\n        <tr>\n          <td>Set Password:</td>\n          <td>\n            <input\n              class=\"input\"\n              type=\"password\"\n              name=\"password\"\n              placeholder=\"***\"\n              required\n            />\n          </td>\n        </tr>\n        <tr>\n          <td></td>\n          <td style=\"text-align: right\">\n            <button type=\"submit\" class=\"button cyan\">Set</button>\n          </td>\n        </tr>\n      </table>\n    </form>\n\n    <table>\n      <tr>\n        <td>\n          <button\n            class=\"button cyan\"\n            hx-post=\"../../web-api/admin/user/{{ user.email|escape }}/generate-link\"\n            hx-target=\"#inviteLink\"\n            hx-trigger=\"click\"\n          >\n            Generate Invite Link\n          </button>\n        </td>\n        <td>\n          <textarea id=\"inviteLink\" readonly></textarea>\n        </td>\n      </tr>\n\n      <tr>\n        <td></td>\n        <td style=\"text-align: right\">\n          <button class=\"button\" id=\"copyInviteLinkBtn\">Copy Link</button>\n        </td>\n      </tr>\n    </table>\n  </div>\n\n  <script>\n    document\n      .getElementById(\"copyInviteLinkBtn\")\n      .addEventListener(\"click\", function () {\n        const textarea = document.getElementById(\"inviteLink\");\n        textarea.select();\n        document.execCommand(\"copy\");\n        displaySuccess(\"Copied invite link to clipboard!\");\n      });\n  </script>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/admin_users.html",
    "content": "<div id=\"admin-users\">\n  <table>\n    <tr>\n      <th>Email</th>\n      <th>Created At</th>\n      <th>Actions</th>\n    </tr>\n\n    {% for user in users %}\n    <tr>\n      <td>{{ user.email|escape }}</td>\n      <td>{{ user.created_at|escape }}</td>\n      <td>\n        <button\n          class=\"button cyan\"\n          hx-target=\"#admin-users\"\n          hx-get=\"../../web/components/admin/users/{{ user.id|escape }}\"\n        >\n          More Info\n        </button>\n        <button\n          class=\"deleteUserBtn button red\"\n          hx-delete=\"../../web-api/admin/user/{{ user.id|escape }}\"\n          hx-confirm=\"Are you sure?\"\n          hx-target=\"closest tr\"\n          hx-swap=\"delete\"\n        >\n          Delete\n        </button>\n      </td>\n    </tr>\n    {% endfor %}\n  </table>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/appflowy_banner.html",
    "content": "<div class=\"display-flex align-items-center\">\n  <img\n    src=\"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png\"\n    width=\"30\"\n    height=\"30\"\n  />\n  <h2 style=\"margin-left: 10px\">AppFlowy Cloud</h2>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/change_password.html",
    "content": "<div>\n  <h3>Password Change</h3>\n  <form hx-post=\"../web-api/change-password\" hx-target=\"#none\">\n    <table>\n      <tr>\n        <td>New Password:</td>\n        <td>\n          <input\n            class=\"input\"\n            type=\"password\"\n            name=\"new_password\"\n            placeholder=\"***\"\n            required\n          />\n        </td>\n      </tr>\n      <tr>\n        <td>Confirm Password:</td>\n        <td>\n          <input\n            class=\"input\"\n            type=\"password\"\n            name=\"confirm_password\"\n            placeholder=\"***\"\n            required\n          />\n        </td>\n      </tr>\n      <tr>\n        <td></td>\n        <td style=\"text-align: right\">\n          <button type=\"submit\" class=\"button cyan\">Confirm Change</button>\n        </td>\n      </tr>\n    </table>\n  </form>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/create_user.html",
    "content": "<div id=\"create-user\">\n  <h4>Please enter the following information to create a new user</h4>\n  <form hx-post=\"../../web-api/admin/user\" hx-target=\"#none\">\n    <table>\n      <tr>\n        <td>Email:</td>\n        <td>\n          <input\n            class=\"input\"\n            name=\"email\"\n            placeholder=\"user@example.com\"\n            required\n          />\n        </td>\n      </tr>\n      <tr>\n        <td>Password:</td>\n        <td>\n          <input\n            class=\"input\"\n            type=\"password\"\n            name=\"password\"\n            placeholder=\"********\"\n            required\n          />\n        </td>\n      </tr>\n      <tr>\n        <td>Require Email Verification:</td>\n        <td>\n          <select name=\"require_email_verification\" class=\"input\">\n            <option value=\"false\">No</option>\n            <option value=\"true\">Yes</option>\n          </select>\n        </td>\n      </tr>\n      <tr>\n        <td></td>\n        <td style=\"text-align: right\">\n          <button class=\"button cyan\" type=\"submit\">Create</button>\n        </td>\n      </tr>\n    </table>\n  </form>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/invite.html",
    "content": "<div id=\"invite-user\">\n  <h4>Invite another user to AppFlowy</h4>\n  <form hx-post=\"../web-api/invite\" hx-target=\"#none\">\n    <table>\n      <tr>\n        <td>Email:</td>\n        <td>\n          <input\n            class=\"input\"\n            name=\"email\"\n            placeholder=\"myfriend@example.com\"\n            required\n          />\n        </td>\n      </tr>\n      <tr>\n        <td></td>\n        <td style=\"text-align: right\">\n          <button class=\"button cyan\" type=\"submit\">Invite</button>\n        </td>\n      </tr>\n    </table>\n  </form>\n\n  <br />\n  <h4>Workspaces shared with you</h4>\n  <div\n    hx-get=\"../web/components/user/shared-workspaces\"\n    hx-trigger=\"workspaceInvitationAccepted from:body\"\n    hx-swap=\"innerHTML\"\n  >\n    {% include \"shared_workspaces.html\" %}\n  </div>\n\n  <br />\n  <h4>Invite another user to your workspace</h4>\n  <table class=\"red-table table\">\n    <thead>\n      <tr>\n        <th>Workspace Name</th>\n        <th>Members</th>\n        <th>Invite</th>\n      </tr>\n    </thead>\n    {% for owned_workspace in owned_workspaces %}\n    <tr>\n      <td>{{ owned_workspace.workspace.workspace_name|escape }}</td>\n      <td>\n        {% for member in owned_workspace.members %} {{ member.email|escape }}\n        <br />\n        {% endfor %}\n      </td>\n      <td>\n        <form\n          hx-post=\"../web-api/workspace/{{ owned_workspace.workspace.workspace_id|escape }}/invite\"\n          hx-target=\"#none\"\n        >\n          <input\n            class=\"input\"\n            name=\"email\"\n            placeholder=\"user1_email@example.com\"\n            required\n          />\n          <button class=\"button cyan\" type=\"submit\">Invite</button>\n        </form>\n      </td>\n    </tr>\n    {% endfor %}\n  </table>\n\n  <br />\n  <h4>Invitation(s) from other user(s)</h4>\n  <table class=\"purple-table table\">\n    <thead>\n      <tr>\n        <th>Workspace Name</th>\n        <th>Inviter</th>\n        <th>Action</th>\n      </tr>\n    </thead>\n    {% for pending_workspace_invitation in pending_workspace_invitations %}\n    <tr>\n      <td>\n        {{ pending_workspace_invitation.workspace_name|default(\"\")|escape }}\n      </td>\n      <td>\n        {{ pending_workspace_invitation.inviter_email|default(\"\")|escape }}\n      </td>\n      <td>\n        <form\n          hx-post=\"../web-api/invite/{{ pending_workspace_invitation.invite_id|escape }}/accept\"\n          hx-target=\"closest tr\"\n          hx-swap=\"delete\"\n        >\n          <button class=\"button cyan\" type=\"submit\">Accept</button>\n        </form>\n      </td>\n    </tr>\n    {% endfor %}\n  </table>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/message.html",
    "content": "<div id=\"message\"></div>\n\n<script>\n  function displayMessage(message, afColor) {\n    var top_message_box = document.createElement(\"div\");\n    top_message_box.className = \"message\";\n    top_message_box.innerHTML = message;\n    top_message_box.style.display = \"block\";\n    top_message_box.style.backgroundColor = afColor;\n    document.body.appendChild(top_message_box);\n    top_message_box.classList.add(\"slideIn\");\n    setTimeout(function () {\n      top_message_box.remove();\n    }, 2500);\n  }\n\n  // e.g. color_name: \"--af-red\"\n  function getAfColor(color_name) {\n    const rootStyle = getComputedStyle(document.documentElement);\n    return rootStyle.getPropertyValue(color_name).trim();\n  }\n\n  function displayHttpStatusAndPayload(response) {\n    response.text().then((text) => {\n      displayFail(`\n      <b>${response.status}: ${response.statusText}</b>\n      <br>${text}`);\n    });\n  }\n\n  function displayHttpFail(statusCode, statusText, responseText) {\n    displayFail(`\n      <b>${statusCode}: ${statusText}</b>\n      <br>${responseText}`);\n  }\n\n  function displaySuccess(message) {\n    displayMessage(message, getAfColor(\"--af-dark-cyan\"));\n  }\n\n  function displayFail(message) {\n    displayMessage(message, getAfColor(\"--af-dark-red\"));\n  }\n</script>\n"
  },
  {
    "path": "admin_frontend/templates/components/navigate.html",
    "content": "<table style=\"border-collapse: collapse\">\n  <tr class=\"nav-item\">\n    <td>Open AppFlowy</td>\n    <td>\n      <div hx-post=\"../web-api/open_app\" class=\"svg-container button\">\n        {% include \"../assets/logo.html\" %}\n      </div>\n    </td>\n  </tr>\n  <tr class=\"nav-item\">\n    <td>Download AppFlowy</td>\n    <td>\n      <div\n        onclick=\"window.location.href='https://appflowy.io/download';\"\n        class=\"svg-container button\"\n      >\n        {% include \"../assets/logo.html\" %}\n      </div>\n    </td>\n  </tr>\n</table>\n"
  },
  {
    "path": "admin_frontend/templates/components/shared_workspaces.html",
    "content": "<table class=\"cyan-table table\">\n  <thead>\n    <tr>\n      <th>Workspace Name</th>\n      <th>Owner Name</th>\n      <th>Action</th>\n    </tr>\n  </thead>\n  {% for shared_workspace in shared_workspaces %}\n  <tr>\n    <td>{{ shared_workspace.workspace_name|escape }}</td>\n    <td>{{ shared_workspace.owner_name|escape }}</td>\n    <td>\n      <button\n        class=\"button red\"\n        hx-post=\"../web-api/workspace/{{ shared_workspace.workspace_id|escape }}/leave\"\n        hx-confirm=\"Are you sure?\"\n        hx-target=\"closest tr\"\n        hx-swap=\"delete\"\n      >\n        Leave\n      </button>\n    </td>\n  </tr>\n  {% endfor %}\n</table>\n"
  },
  {
    "path": "admin_frontend/templates/components/sidebar.html",
    "content": "<div id=\"sidebar\">\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../web/components/user/navigate\"\n    data-section=\"navigate\"\n  >\n    Navigate\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../web/components/user/change-password\"\n    data-section=\"change-password\"\n  >\n    Change Password\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../web/components/user/invite\"\n    data-section=\"invite\"\n  >\n    Invite\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../web/components/user/user-usage\"\n    data-section=\"user-usage\"\n  >\n    User Usage\n  </div>\n  <div\n    class=\"sidebar-item\"\n    hx-target=\"#sidebar-content\"\n    hx-get=\"../web/components/user/workspace-usage\"\n    data-section=\"workspace-usage\"\n  >\n    Workspace Usage\n  </div>\n</div>\n\n<script>\n  document.addEventListener(\"DOMContentLoaded\", (event) => {\n    const frag = window.location.href.split(\"#\");\n    if (frag.length > 1) {\n      const section = frag[1];\n      const sidebarItems = document.querySelectorAll(\".sidebar-item\");\n\n      sidebarItems.forEach((item) => {\n        if (item.getAttribute(\"data-section\") === section) {\n          item.click();\n        }\n      });\n    }\n  });\n</script>\n"
  },
  {
    "path": "admin_frontend/templates/components/top_menu_bar.html",
    "content": "<div id=\"top-menu-bar\">\n  <div id=\"top-menu-bar-left\">\n    {% include \"../assets/logo.html\" %}\n    <h2>&nbsp; AppFlowy Cloud &nbsp;</h2>\n\n    <div\n      hx-target=\"#sidebar-content\"\n      hx-get=\"../web/components/user/user\"\n      class=\"button cyan\"\n    >\n      {{ user.email|escape }}\n    </div>\n    <div\n      hx-delete=\"../web-api/delete-account\"\n      hx-confirm=\"This will erase all data associated with this account. Are you sure?\"\n      class=\"button red\"\n    >\n      Delete Account\n    </div>\n  </div>\n\n  <div id=\"top-menu-bar-right\">\n    <!-- prettier-ignore -->\n    {% if is_admin %}\n    <div id=\"adminBtn\" class=\"button red\">Admin</div>\n    <script>\n      document\n        .getElementById(\"adminBtn\")\n        .addEventListener(\"click\", function () {\n          window.location.href = \"../web/admin/home\";\n        });\n    </script>\n    {% endif %}\n\n    <div class=\"button yellow\" id=\"logoutBtn\" hx-post=\"../web-api/logout\">\n      Logout\n    </div>\n  </div>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/user_details.html",
    "content": "<div>\n  <p>Email: {{ user.email|escape }}</p>\n  <p>Role: {{ user.role|escape }}</p>\n  <p>Phone: {{ user.phone|escape }}</p>\n  <p>Email Confirmed At: {{ user.email_confirmed_at|default(\"-\")|escape }}</p>\n  <p>Phone Confirmed At: {{ user.phone_confirmed_at|default(\"-\")|escape }}</p>\n  <p>Last Sign In At: {{ user.last_sign_in_at|default(\"-\")|escape }}</p>\n  <p>Created At: {{ user.created_at|escape }}</p>\n  <p>Updated At: {{ user.updated_at|escape }}</p>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/user_usage.html",
    "content": "<div>\n  <table class=\"yellow-table table\">\n    <thead>\n      <tr>\n        <th>Type</th>\n        <th>Current</th>\n        <th>Limit</th>\n      </tr>\n    </thead>\n    <tr>\n      <td>\n        Workspaces\n      </td>\n      <td>\n        {{ workspace_count|escape }}\n      </td>\n      <td>\n        {{ workspace_limit|escape }}\n      </td>\n    </tr>\n  </table>\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/components/workspace_usage.html",
    "content": "<div>\n  <table class=\"red-table table\">\n    <thead>\n      <tr>\n        <th>Workspace Name</th>\n        <th>Members</th>\n        <th>Document Storage</th>\n        <th>Object Storage</th>\n      </tr>\n    </thead>\n\n    {% for workspace_usage in workspace_usages %}\n      <tr>\n        <td> {{ workspace_usage.name|escape }} </td>\n        <td> {{ workspace_usage.member_count|escape }} </td>\n        <td> {{ workspace_usage.total_doc_size|escape }} </td>\n        <td> {{ workspace_usage.total_blob_size|escape }} </td>\n      </tr>\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/layouts/base.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <link href=\"{{ path_prefix }}/assets/base.css\" rel=\"stylesheet\" />\n    <link href=\"{{ path_prefix }}/assets/message.css\" rel=\"stylesheet\" />\n    <title>{% block title %}{{ title|escape }}{% endblock %}</title>\n    <script\n      src=\"https://unpkg.com/htmx.org@1.9.6\"\n      integrity=\"sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni\"\n      crossorigin=\"anonymous\"\n    ></script>\n    {% block head %}{% endblock %}\n  </head>\n  <body>\n    <!-- prettier-ignore -->\n    {% include \"components/message.html\" %}\n\n    <div id=\"content\">{% block content %}{% endblock %}</div>\n\n    <!-- place for htmx result that are not updating document -->\n    <!-- hx-target=\"#none\" -->\n    <div id=\"none\" style=\"display: none\"></div>\n  </body>\n</html>\n\n<script>\n  function findButton(event) {\n    let button = event.target.querySelector(\".button\");\n    if (button) {\n      return button;\n    } else {\n      return event.target.closest(\".button\");\n    }\n  }\n\n  document.body.addEventListener(\"htmx:beforeRequest\", function (event) {\n    const closeButton = findButton(event);\n    if (closeButton) {\n      closeButton.classList.add(\"loading-button\");\n      closeButton.disabled = true;\n    }\n  });\n\n  document.body.addEventListener(\"htmx:afterRequest\", function (event) {\n    const closeButton = findButton(event);\n    if (closeButton) {\n      closeButton.classList.remove(\"loading-button\");\n      closeButton.disabled = false;\n    }\n\n    const detail = event.detail;\n    if (detail.failed) {\n      const xhr = detail.xhr;\n      displayHttpFail(xhr.status, xhr.statusText, xhr.responseText);\n    } else if (detail.target.id === \"none\") {\n      displaySuccess(JSON.parse(detail.xhr.responseText).message);\n    }\n  });\n</script>\n"
  },
  {
    "path": "admin_frontend/templates/pages/admin_home.html",
    "content": "<!-- prettier-ignore -->\n{% extends \"layouts/base.html\" %}\n\n<!-- prettier-ignore -->\n{% block title %} AppFlowy Cloud Admin {% endblock %}\n\n<!-- prettier-ignore -->\n{% block head %}\n<link href=\"../../assets/sidebar.css\" rel=\"stylesheet\" />\n<link href=\"../../assets/top_menu_bar.css\" rel=\"stylesheet\" />\n<link href=\"../../assets/home.css\" rel=\"stylesheet\" />\n<link href=\"../../assets/navigate.css\" rel=\"stylesheet\" />\n{% endblock %}\n\n<!-- prettier-ignore -->\n{% block content %}\n\n<!-- prettier-ignore -->\n{% include \"components/admin_top_menu_bar.html\" %}\n\n<div id=\"home\" style=\"border-top: 1px solid\">\n  <div style=\"border-right: 1px solid\">\n    {% include \"components/admin_sidebar.html\" %}\n  </div>\n  <div style=\"margin: 16px\" id=\"sidebar-content\">\n    {% include \"components/admin_navigate.html\" %}\n  </div>\n</div>\n\n{% endblock %}\n"
  },
  {
    "path": "admin_frontend/templates/pages/home.html",
    "content": "<!-- prettier-ignore -->\n{% extends \"layouts/base.html\" %}\n\n<!-- prettier-ignore -->\n{% block title %} AppFlowy Cloud {% endblock %}\n\n<!-- prettier-ignore -->\n{% block head %}\n<link href=\"{{ path_prefix }}/assets/sidebar.css\" rel=\"stylesheet\" />\n<link href=\"{{ path_prefix }}/assets/top_menu_bar.css\" rel=\"stylesheet\" />\n<link href=\"{{ path_prefix }}/assets/home.css\" rel=\"stylesheet\" />\n<link href=\"{{ path_prefix }}/assets/navigate.css\" rel=\"stylesheet\" />\n{% endblock %}\n\n<!-- prettier-ignore -->\n{% block content %}\n\n<!-- prettier-ignore -->\n{% include \"components/top_menu_bar.html\" %}\n\n<div id=\"home\" style=\"border-top: 1px solid\">\n  <div style=\"border-right: 1px solid\">\n    {% include \"components/sidebar.html\" %}\n  </div>\n  <div style=\"margin: 16px\" id=\"sidebar-content\">\n    {% include \"components/navigate.html\" %}\n  </div>\n</div>\n<script>\n  window.history.replaceState(null, \"\", \"{{ path_prefix }}/web/home\");\n</script>\n\n{% endblock %}\n"
  },
  {
    "path": "admin_frontend/templates/pages/login.html",
    "content": "<!-- prettier-ignore -->\n{% extends \"layouts/base.html\" %}\n\n<!-- prettier-ignore -->\n{% block title %} AppFlowy Cloud Login {% endblock %}\n\n<!-- prettier-ignore -->\n{% block head %}\n<link href=\"../assets/login.css\" rel=\"stylesheet\" />\n<link href=\"../assets/google/logo.css\" rel=\"stylesheet\" />\n{% endblock %}\n\n<!-- prettier-ignore -->\n{% block content %}\n<div id=\"login-parent\">\n  <div id=\"login-signin\">\n    <div id=\"login-splash\">\n      {% include \"../assets/logo.html\" %}\n      <h2 style=\"padding: 16px\">AppFlowy Cloud</h2>\n    </div>\n\n    <h3>Email Login</h3>\n    <form>\n      <table style=\"width: 100%\">\n        {% if let Some(redirect_to) = redirect_to %}\n        <input type=\"hidden\" name=\"redirect_to\" value=\"{{ redirect_to }}\" />\n        {% endif %}\n\n        <tr>\n          <td>Email</td>\n          <td>\n            <input\n              class=\"input\"\n              style=\"width: 100%\"\n              type=\"text\"\n              id=\"email\"\n              name=\"email\"\n              placeholder=\"user@example.com\"\n            />\n          </td>\n        </tr>\n        <tr>\n          <td>Password &nbsp</td>\n          <td>\n            <input\n              class=\"input\"\n              style=\"width: 100%\"\n              type=\"password\"\n              id=\"password\"\n              name=\"password\"\n              placeholder=\"********(optional)\"\n            />\n          </td>\n        </tr>\n      </table>\n\n      <small style=\"color: #888\"\n        ><i>\n          (Magic link will be sent to email if password is not provided)\n        </i></small\n      >\n      <div style=\"display: flex; margin: 8px 0px\">\n        <button\n          hx-post=\"../web-api/signin\"\n          hx-target=\"#none\"\n          class=\"button cyan\"\n          type=\"submit\"\n          style=\"width: 100%; padding: 8px 8px\"\n        >\n          Sign In\n        </button>\n        <button\n          hx-post=\"../web-api/signup\"\n          hx-target=\"#none\"\n          class=\"button purple\"\n          type=\"submit\"\n          style=\"width: 100%; padding: 8px 8px; color: white\"\n        >\n          Sign Up\n        </button>\n      </div>\n    </form>\n\n    <!-- Load OAuth Providers if configured -->\n    {% if oauth_providers.len() > 0 %}\n    <br />\n    <table style=\"width: 100%; border-collapse: collapse\">\n      <tr style=\"display: flex; align-items: center\">\n        <td style=\"width: 100%; margin: auto\">\n          <hr class=\"divider\" />\n        </td>\n        <td style=\"flex: 1; text-align: center\">&nbsp;or&nbsp;</td>\n        <td style=\"width: 100%; margin: auto\">\n          <hr class=\"divider\" />\n        </td>\n      </tr>\n    </table>\n\n    <h3>OAuth Login</h3>\n    <div id=\"oauth-container\">\n      <div\n        style=\"\n          display: flex;\n          flex-wrap: wrap;\n          align-items: center;\n          justify-content: center;\n        \"\n      >\n        {% for provider in oauth_providers %}\n        <a\n          href=\"/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|escape }}\"\n          style=\"text-decoration: none; color: inherit\"\n        >\n          <div\n            style=\"\n              display: flex;\n              align-items: center;\n              border: 1px solid #384967;\n              margin: 4px;\n              border-radius: 4px;\n              height: 64px;\n            \"\n          >\n            <div>&nbsp&nbsp{{ provider }}</div>\n            <div class=\"oauth-icon\">\n              <div\n                hx-get=\"../assets/{{ provider|escape }}/logo.html\"\n                hx-trigger=\"load\"\n                hx-swap=\"outerHTML\"\n              ></div>\n            </div>\n          </div>\n        </a>\n        {% endfor %}\n      </div>\n    </div>\n    {% endif %}\n\n    <span> &nbsp </span>\n    <div style=\"max-width: 256px; display: flex; align-items: center\">\n      <img\n        src=\"https://cdn.prod.website-files.com/5c14e387dab576fe667689cf/61e1116779fc0a9bd5bdbcc7_Frame%206.png\"\n        alt=\"kofi\"\n        width=\"32\"\n        height=\"32\"\n      />\n      <i>\n        &nbsp Support AppFlowy on <a href=\"https://ko-fi.com/appflowy\">Ko-fi</a>\n      </i>\n    </div>\n\n    <span> &nbsp </span>\n    <div style=\"max-width: 256px\">\n      <small style=\"color: #888; text-align: center\"\n        ><i>\n          &nbsp By clicking logging in or signing up, you confirm that you have\n          read, understood, and agreed to AppFlowy's\n          <a href=\"https://appflowy.io/terms\">Terms</a> and\n          <a href=\"https://appflowy.io/privacy\">Privacy Policy</a>.\n        </i></small\n      >\n    </div>\n  </div>\n  {% endblock %}\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/pages/login_callback.html",
    "content": "<script>\n  window.onload = function () {\n    const current_url = window.location.href;\n    if (current_url.includes(\"/login-callback\")) {\n      var redirect_url = current_url.replace(\n        \"/login-callback\",\n        \"/login-callback-query\",\n      );\n      if (redirect_url.includes(\"?\")) {\n        // If '?' exists, replace '#' with '&'\n        redirect_url = redirect_url.replace(\"#\", \"&\");\n      } else {\n        // If '?' does not exist, replace '#' with '?'\n        redirect_url = redirect_url.replace(\"#\", \"?\");\n      }\n      window.location.href = redirect_url;\n    }\n  };\n</script>\n"
  },
  {
    "path": "admin_frontend/templates/pages/login_v2.html",
    "content": "<!-- prettier-ignore -->\n{% extends \"layouts/base.html\" %}\n\n<!-- prettier-ignore -->\n{% block title %} AppFlowy Cloud Login {% endblock %}\n\n<!-- prettier-ignore -->\n{% block head %}\n<link href=\"../assets/login.css\" rel=\"stylesheet\" />\n<link href=\"../assets/google/logo.css\" rel=\"stylesheet\" />\n{% endblock %}\n\n<!-- prettier-ignore -->\n{% block content %}\n<div id=\"login-parent\">\n  <div id=\"login-signin\">\n    <div id=\"login-splash\">\n      {% include \"../assets/logo.html\" %}\n    </div>\n    <h2>Welcome to AppFlowy</h3>\n    <form>\n      {% if let Some(redirect_to) = redirect_to %}\n        <input type=\"hidden\" name=\"redirect_to\" value=\"{{ redirect_to }}\">\n      {% endif %}\n\n      <div>\n        <input\n          class=\"input\"\n          style=\"width: 100%; border-radius: 8px; padding: 8px; margin: 4px; margin-bottom: 8px;\"\n          type=\"text\"\n          id=\"email\"\n          name=\"email\"\n          placeholder=\"Please enter your email address\"\n        />\n        <button\n          hx-post=\"../web-api/signin\"\n          hx-target=\"#none\"\n          class=\"button cyan\"\n          type=\"submit\"\n          style=\"width: 100%; padding: 8px 8px; border-radius: 8px; margin-top: 8px\"\n        >\n          Continue\n        </button>\n      </div>\n    </form>\n\n    <!-- Load OAuth Providers if configured -->\n    {% if oauth_providers.len() > 0 %}\n    <table style=\"width: 100%; margin: 16px; border-collapse: collapse;\">\n      <tr style=\"display: flex; align-items: center;\">\n        <td style=\"width: 100%; margin: auto;\">\n          <hr class=\"divider\" />\n        </td>\n        <td style=\"flex: 1; text-align: center;\">&nbsp;or&nbsp;</td>\n        <td style=\"width: 100%; margin: auto;\">\n          <hr class=\"divider\" />\n        </td>\n      </tr>\n    </table>\n\n    <div id=\"oauth-container\">\n      <div style=\"display: flex; align-items: center; justify-content: center; flex-direction: column; width: 100%;\">\n        {% for provider in oauth_providers %}\n        <div class=\"oauth-item-inner\">\n          <a\n            href=\"/gotrue/authorize?provider={{ provider|escape }}&redirect_to={{ oauth_redirect_to|escape }}\"\n            style=\"text-decoration: none; color: inherit\"\n          >\n            <div style=\"display: flex; align-items: center; justify-content: center; color: inherit\">\n              <div>\n                <div\n                  hx-get=\"../assets/login/{{ provider|escape }}.svg\"\n                  hx-trigger=\"load\"\n                  hx-swap=\"innerHTML\"\n                ></div>\n              </div>\n              <span> &nbsp </span>\n              <div> Continue with {{ provider }} </div>\n            </div>\n          </a>\n        </div>\n        {% endfor %}\n\n        <script>\n          document.addEventListener(\"htmx:afterSwap\", (event) => {\n            const hxGet = event.target.getAttribute(\"hx-get\");\n            if (hxGet && hxGet.includes(\"assets/login\")) {\n              const svg = event.target.querySelector(\"svg\");\n              if (svg) {\n                svg.style.width = \"24px\";\n                svg.style.height = \"24px\";\n                svg.style.margin = \"8px\";\n              }\n              if (hxGet.includes(\"Discord\")) {\n                svg.style.transform = \"translateY(4px)\";\n              } else if (hxGet.includes(\"Google\")) {\n                svg.style.transform = \"translateY(2px)\";\n              } else if (hxGet.includes(\"Apple\")) {\n                svg.style.transform = \"translateY(2px)\";\n                svg.style.parentNode.filter = \"invert(1)\";\n              } else if (hxGet.includes(\"Github\")) {\n                svg.style.transform = \"translateY(2px)\";\n                svg.style.parentNode.filter = \"invert(1)\";\n              }\n            }\n          });\n        </script>\n      </div>\n    </div>\n    {% endif %}\n\n    <span> &nbsp </span>\n    <span> &nbsp </span>\n    <span> &nbsp </span>\n    <div style=\"max-width: 256px\">\n      <small style=\"color: #888; text-align: center; display: block\">\n        By clicking \"Continue\" above, you agreed to AppFlowy's\n        <a href=\"https://appflowy.io/terms\">Terms</a> and\n        <a href=\"https://appflowy.io/privacy\">Privacy Policy</a>.\n      </small>\n    </div>\n  </div>\n  {% endblock %}\n</div>\n"
  },
  {
    "path": "admin_frontend/templates/pages/open_appflowy_or_download.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"utf-8\">\n    <title>AppFlowy</title>\n  </head>\n  <body>\n    <script>\n      const getDeviceType = () => {\n        const ua = navigator.userAgent;\n\n        if (/(iPad|iPhone|iPod)/g.test(ua)) {\n          return 'iOS';\n        } else if (/Android/g.test(ua)) {\n          return 'Android';\n        } else {\n          return 'Desktop';\n        }\n      };\n      const deviceType = getDeviceType();\n      const isMobile = deviceType !== 'Desktop';\n      const getFallbackLink = () => {\n\n        if (deviceType === 'iOS') {\n          return 'https://testflight.apple.com/join/6CexvkDz';\n        } else if (deviceType === 'Android') {\n          return 'https://play.google.com/store/apps/details?id=io.appflowy.appflowy';\n        } else {\n          return 'https://appflowy.io/download/#pop';\n        }\n      };\n\n      const getDuration = () => {\n        switch (deviceType) {\n          case 'iOS':\n            return 250;\n          default:\n            return 1500;\n        }\n      }\n\n      const APPFLOWY_SCHEME = 'appflowy-flutter://';\n\n      const iframe = document.createElement('iframe');\n      iframe.style.display = 'none'\n      iframe.src = APPFLOWY_SCHEME;\n\n      const openSchema = () => {\n        if (isMobile) return window.location.href = APPFLOWY_SCHEME;\n        document.body.appendChild(iframe);\n        setTimeout(() => {\n          document.body.removeChild(iframe);\n        }, 1000);\n      };\n\n      const openAppFlowy = (force) => {\n        openSchema();\n\n        if (force) return;\n\n        const initialTime = new Date();\n        let interactTime = initialTime;\n        let waitTime = 0;\n        const duration = getDuration();\n\n        const updateInteractTime = () => {\n          interactTime = new Date();\n        };\n\n        document.removeEventListener('mousemove', updateInteractTime);\n        document.removeEventListener('mouseenter', updateInteractTime);\n\n        const checkOpen = setInterval(() => {\n          waitTime = new Date() - initialTime;\n\n          if (waitTime > duration) {\n            clearInterval(checkOpen);\n            if (isMobile || new Date() - interactTime < duration) {\n              document.getElementById('download-link').click();\n            }\n          }\n        }, 20);\n\n        if (!isMobile) {\n          document.addEventListener('mouseenter', updateInteractTime);\n          document.addEventListener('mousemove', updateInteractTime);\n        }\n\n        document.addEventListener('visibilitychange', () => {\n          const isHidden = document.hidden;\n          if (isHidden) {\n            clearInterval(checkOpen);\n          }\n        });\n\n        window.onpagehide = () => {\n          clearInterval(checkOpen);\n        };\n\n        window.onbeforeunload = () => {\n          clearInterval(checkOpen);\n        };\n\n      };\n      document.addEventListener(\"DOMContentLoaded\", function() {\n        document.getElementById('open-appflowy').addEventListener('click', (e) => {\n          e.preventDefault();\n          openAppFlowy(true);\n        });\n\n        document.getElementById('download-link').addEventListener('click', (e) => {\n          e.preventDefault();\n          window.open(getFallbackLink(), '_current');\n        });\n\n        openAppFlowy();\n\n      });\n    </script>\n\n    <h1>Opening AppFlowy</h1>\n    <p>If AppFlowy does not open, you can click <a id=\"open-appflowy\" href=\"#\">here</a> to launch the app.</p>\n    <p>If AppFlowy is not installed, you can <a id=\"download-link\" href=\"#\">download AppFlowy manually</a>.</p>\n  </body>\n</html>\n"
  },
  {
    "path": "admin_frontend/templates/pages/payment_success_redirect.html",
    "content": "<html>\n  <head>\n    <title>AppFlowy - Redirecting</title>\n  </head>\n  <body>\n    <script type=\"text/javascript\">\n      window.location.replace(`appflowy-flutter://payment-success/${window.location.search}`);\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "admin_frontend/templates/pages/redirect.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"refresh\" content=\"0;url={{ redirect_url|escape }}\">\n    <title>Redirecting...</title>\n  </head>\n  <body>\n    <p>If you are not redirected, <a href=\"{{ redirect_url|escape }}\">click here</a>.</p>\n  </body>\n</html>\n"
  },
  {
    "path": "admin_frontend/tests/main.rs",
    "content": "mod oauth;\nmod utils;\n"
  },
  {
    "path": "admin_frontend/tests/oauth/mod.rs",
    "content": "use admin_frontend::models::OAuthRedirect;\nuse admin_frontend::models::OAuthRedirectToken;\nuse base64::engine::Engine;\nuse base64::prelude::BASE64_STANDARD_NO_PAD;\nuse gotrue_entity::dto::GotrueTokenResponse;\nuse reqwest::StatusCode;\nuse reqwest::Url;\nuse sha2::Digest;\n\nuse crate::utils::AdminFrontendClient;\n\n#[tokio::test]\nasync fn oauth_sign_in() {\n  let mut af_client = AdminFrontendClient::new();\n  af_client\n    .web_api_sign_in(\"admin@example.com\", \"password\")\n    .await;\n\n  let code_challenge_orginal = \"hello123\";\n  let code_challenge_sha256 = {\n    let mut hasher = sha2::Sha256::new();\n    hasher.update(code_challenge_orginal.as_bytes());\n    hasher.finalize().to_vec()\n  };\n\n  // OAuth Param\n  let code_challenge = BASE64_STANDARD_NO_PAD.encode(code_challenge_sha256);\n  let client_id = \"appflowy_cloud\";\n  let state = \"state123\";\n\n  {\n    // redirect url not in allowed list\n    let resp = af_client\n      .web_api_oauth_redirect(&OAuthRedirect {\n        client_id: client_id.to_string(),\n        state: state.to_string(),\n        redirect_uri: \"https://mywebsite.com\".to_string(),\n        response_type: \"code\".to_string(),\n        code_challenge: Some(code_challenge.clone()),\n        code_challenge_method: Some(\"S256\".to_string()),\n      })\n      .await;\n    assert_eq!(resp.status(), StatusCode::BAD_REQUEST);\n    assert_eq!(\n      resp.text().await.unwrap(),\n      \"invalid redirect_uri: https://mywebsite.com, allowable_uris: http://localhost:3000\"\n    );\n  }\n\n  {\n    let resp = af_client\n      .web_api_oauth_redirect(&OAuthRedirect {\n        client_id: client_id.to_string(),\n        state: state.to_string(),\n        redirect_uri: \"http://localhost:3000\".to_string(),\n        response_type: \"code\".to_string(),\n        code_challenge: Some(code_challenge.clone()),\n        code_challenge_method: Some(\"S256\".to_string()),\n      })\n      .await;\n    assert_eq!(resp.status(), StatusCode::SEE_OTHER);\n\n    let redirect_url = resp.headers().get(\"location\").unwrap().to_str().unwrap();\n    let (code, ret_state) = extract_code_and_state(redirect_url);\n    assert_eq!(ret_state, state);\n\n    {\n      // did not provide code_verifier\n      let resp = af_client\n        .web_api_oauth_redirect_token(&OAuthRedirectToken {\n          code: code.clone(),\n          grant_type: \"authorization_code\".to_string(),\n          ..Default::default()\n        })\n        .await;\n      assert_eq!(resp.status(), StatusCode::BAD_REQUEST);\n      assert_eq!(resp.text().await.unwrap(), \"missing code_verifier\");\n    }\n\n    {\n      let resp = af_client\n        .web_api_oauth_redirect_token(&OAuthRedirectToken {\n          code,\n          grant_type: \"authorization_code\".to_string(),\n          code_verifier: Some(code_challenge_orginal.to_string()),\n          ..Default::default()\n        })\n        .await;\n      assert_eq!(resp.status(), StatusCode::OK);\n      let token_str = resp.text().await.unwrap();\n      let _gotrue_token: GotrueTokenResponse = serde_json::from_str(&token_str).unwrap();\n    }\n  }\n}\n\nfn extract_code_and_state(url_str: &str) -> (String, String) {\n  // Parse the URL\n  let url = Url::parse(url_str).expect(\"Failed to parse URL\");\n\n  // Extract the query parameters\n  let code = url\n    .query_pairs()\n    .find(|(key, _)| key == \"code\")\n    .map(|(_, value)| value.to_string())\n    .unwrap_or_else(|| \"code not found\".to_string());\n\n  let state = url\n    .query_pairs()\n    .find(|(key, _)| key == \"state\")\n    .map(|(_, value)| value.to_string())\n    .unwrap_or_else(|| \"state not found\".to_string());\n\n  (code, state)\n}\n"
  },
  {
    "path": "admin_frontend/tests/utils/mod.rs",
    "content": "pub mod test_config;\n\nuse admin_frontend::{\n  config::Config,\n  models::{OAuthRedirect, OAuthRedirectToken, WebApiLoginRequest},\n};\nuse test_config::TestConfig;\n\npub struct AdminFrontendClient {\n  test_config: TestConfig,\n  #[allow(dead_code)]\n  server_config: Config,\n  session_id: Option<String>,\n  http_client: reqwest::Client,\n}\n\nimpl AdminFrontendClient {\n  pub fn new() -> Self {\n    dotenvy::dotenv().ok();\n\n    let server_config = Config::from_env().unwrap();\n    let test_config = TestConfig::from_env();\n    let http_client = reqwest::Client::new();\n    Self {\n      server_config,\n      session_id: None,\n      http_client,\n      test_config,\n    }\n  }\n\n  pub async fn web_api_sign_in(&mut self, email: &str, password: &str) {\n    let url = format!(\n      \"{}{}/web-api/signin\",\n      self.test_config.hostname, self.server_config.path_prefix\n    );\n    let resp = self\n      .http_client\n      .post(&url)\n      .form(&WebApiLoginRequest {\n        email: email.to_string(),\n        password: password.to_string(),\n        redirect_to: None,\n      })\n      .send()\n      .await\n      .unwrap();\n    let resp = check_resp(resp).await;\n    let c = resp.cookies().find(|c| c.name() == \"session_id\").unwrap();\n    self.session_id = Some(c.value().to_string());\n  }\n\n  pub async fn web_api_oauth_redirect(\n    &mut self,\n    oauth_redirect: &OAuthRedirect,\n  ) -> reqwest::Response {\n    let url = format!(\n      \"{}{}/web-api/oauth-redirect\",\n      self.test_config.hostname, self.server_config.path_prefix\n    );\n    let http_client = reqwest::Client::builder()\n      .redirect(reqwest::redirect::Policy::none())\n      .build()\n      .unwrap();\n\n    http_client\n      .get(&url)\n      .header(\"Cookie\", format!(\"session_id={}\", self.session_id()))\n      .query(oauth_redirect)\n      .send()\n      .await\n      .unwrap()\n  }\n\n  pub async fn web_api_oauth_redirect_token(\n    &mut self,\n    oauth_redirect: &OAuthRedirectToken,\n  ) -> reqwest::Response {\n    let url = format!(\n      \"{}{}/web-api/oauth-redirect/token\",\n      self.test_config.hostname, self.server_config.path_prefix\n    );\n    self\n      .http_client\n      .get(&url)\n      .header(\"Cookie\", format!(\"session_id={}\", self.session_id()))\n      .query(oauth_redirect)\n      .send()\n      .await\n      .unwrap()\n  }\n\n  fn session_id(&self) -> &str {\n    self.session_id.as_ref().unwrap()\n  }\n}\n\nasync fn check_resp(resp: reqwest::Response) -> reqwest::Response {\n  if resp.status() != 200 {\n    println!(\"resp: {:#?}\", resp);\n    let payload = resp.text().await.unwrap();\n    panic!(\"payload: {:#?}\", payload)\n  }\n  resp\n}\n"
  },
  {
    "path": "admin_frontend/tests/utils/test_config.rs",
    "content": "pub struct TestConfig {\n  pub hostname: String,\n}\n\nimpl TestConfig {\n  pub fn from_env() -> Self {\n    dotenvy::dotenv().ok();\n\n    let hostname =\n      std::env::var(\"ADMIN_FRONTEND_TEST_HOSTNAME\").unwrap_or(\"http://localhost:3000\".to_string());\n    TestConfig { hostname }\n  }\n}\n"
  },
  {
    "path": "assets/mailer_templates/build_production/access_request.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Request to join the workspace</title>\n  <style>\n    .hover-opacity-90:hover {\n      opacity: 0.9 !important\n    }\n    @media (max-width: 600px) {\n      .sm-px-4 {\n        padding-left: 16px !important;\n        padding-right: 16px !important\n      }\n      .sm-py-12 {\n        padding-top: 48px !important;\n        padding-bottom: 48px !important\n      }\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div style=\"display: none\">\n    Approve a user's request to join the workspace.\n    &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847;\n  </div>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Request to join the workspace\" lang=\"en\">\n    <div class=\"sm-px-4 sm-py-12\" style=\"background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 552px; max-width: 100%\">\n            <div style=\"width: 100%; text-align: center\">\n              <img src=\"{{ user_icon_url }}\" width=\"48px\" height=\"48px\" alt=\"{{ username }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; border-radius: 9999px; object-fit: cover\">\n            </div>\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px\">\n              <span style=\"font-size: 30px; font-weight: 700\">{{ username }}</span>\n              <span>has requested access to </span>\n              <span style=\"font-size: 30px; font-weight: 700;\">{{ workspace_name }}</span>\n            </p>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%\"></div>\n            <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n              <tr>\n                <td style=\"width: 60px\">\n                  <div style=\"margin-right: 8px; height: 60px; width: 60px; overflow: hidden; border-radius: 16px; background-color: #fff; border: 2px solid black\">\n                    <img src=\"{{ workspace_icon_url }}\" width=\"100%\" height=\"100%\" alt=\"{{ workspace_name }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; object-fit: cover;\">\n                  </div>\n                </td>\n                <td>\n                  <div style=\"margin-bottom: 8px; font-weight: 700\">{{ workspace_name }}</div>\n                  <div style=\"font-size: 14px; color: #64748b\">\n                    {{ workspace_member_count }} members\n                  </div>\n                </td>\n              </tr>\n            </table>\n            <div style=\"text-align: center;\">\n              <a href=\"{{ approve_url }}\" class=\"hover-opacity-90\" style=\"margin-top: 32px; margin-bottom: 32px; display: inline-block; width: 60%; cursor: pointer; border-radius: 16px; padding: 16px 24px; color: #f8fafc; text-decoration: none; background-color: #9327ff; font-size: 20px; font-weight: 400; line-height: 20px\">\n                <!--[if mso]>\n      <i style=\"mso-font-width: 150%; mso-text-raise: 30px\" hidden>&amp;emsp;</i>\n    <![endif]-->\n                <span style=\"mso-text-raise: 16px\">\n            <div style=\"font-size: 24px; font-weight: 500\">Approve request</div>\n          </span>\n                <!--[if mso]>\n      <i hidden=\"\" style=\"mso-font-width: 150%;\">&amp;emsp;&amp;#8203;</i>\n    <![endif]-->\n              </a>\n            </div>\n            <div style=\"margin-left: auto; margin-right: auto; width: 70%; text-align: center; font-size: 14px; line-height: 18px; color: #64748b\">\n              By clicking \"Approve request\" above, the user will be added to the\n              workspace.\n            </div>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%;\"></div>\n          </td>\n        </tr>\n        <tr>\n          <td style=\"padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569\">\n            <p style=\"margin: 0 0 16px; cursor: pointer; text-transform: uppercase\">\n              <a href=\"https://appflowy.io\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png\" width=\"150px\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\" alt=\"\">\n              </a>\n            </p>\n            <p style=\"margin: 0; font-size: 14px; font-weight: 500; color: #000;\">\n              Bring projects, knowledge, and teams together with the power of AI.\n            </p>\n            <p style=\"cursor: default\">\n              <a href=\"https://twitter.com/appflowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n            </p>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/access_request_approved_notification.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Your access request has been approved</title>\n  <style>\n    .hover-opacity-90:hover {\n      opacity: 0.9 !important\n    }\n    @media (max-width: 600px) {\n      .sm-px-4 {\n        padding-left: 16px !important;\n        padding-right: 16px !important\n      }\n      .sm-py-12 {\n        padding-top: 48px !important;\n        padding-bottom: 48px !important\n      }\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div style=\"display: none\">\n    Workspace access request approved notification\n    &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847;\n  </div>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Your access request has been approved\" lang=\"en\">\n    <div class=\"sm-px-4 sm-py-12\" style=\"background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 552px; max-width: 100%\">\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px\">\n              <span>Your request to access </span>\n              <span style=\"font-size: 30px; font-weight: 700\">{{ workspace_name }}</span>\n              <span> has been approved </span>\n            </p>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%\"></div>\n            <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n              <tr>\n                <td style=\"width: 60px\">\n                  <div style=\"margin-right: 8px; height: 60px; width: 60px; overflow: hidden; border-radius: 16px; background-color: #fff; border: 2px solid black\">\n                    <img src=\"{{ workspace_icon_url }}\" width=\"100%\" height=\"100%\" alt=\"{{ workspace_name }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; object-fit: cover\">\n                  </div>\n                </td>\n                <td>\n                  <div style=\"margin-bottom: 8px; font-weight: 700\">{{ workspace_name }}</div>\n                  <div style=\"font-size: 14px; color: #64748b\">\n                    {{ workspace_member_count }} members\n                  </div>\n                </td>\n              </tr>\n            </table>\n            <div style=\"text-align: center;\">\n              <a href=\"{{ launch_workspace_url }}\" class=\"hover-opacity-90\" style=\"margin-top: 32px; margin-bottom: 32px; display: inline-block; width: 60%; cursor: pointer; border-radius: 16px; padding: 16px 24px; color: #f8fafc; text-decoration: none; background-color: #9327ff; font-size: 20px; font-weight: 400; line-height: 20px\">\n                <!--[if mso]>\n      <i style=\"mso-font-width: 150%; mso-text-raise: 30px\" hidden>&amp;emsp;</i>\n    <![endif]-->\n                <span style=\"mso-text-raise: 16px\">\n            <div style=\"font-size: 24px; font-weight: 500\">View workspace</div>\n          </span>\n                <!--[if mso]>\n      <i hidden=\"\" style=\"mso-font-width: 150%;\">&amp;emsp;&amp;#8203;</i>\n    <![endif]-->\n              </a>\n            </div>\n            <div style=\"\n              margin-left: auto;\n              margin-right: auto;\n              width: 70%;\n              text-align: center;\n              font-size: 14px;\n              line-height: 18px;\n              color: #64748b;\n            \">\n              By clicking \"View workspace\" above, you confirm that you have read,\n              understood, and agreed to AppFlowy's\n              <a href=\"https://appflowy.io/terms/app\" style=\"color: #64748b\">Terms &amp; Conditions</a>\n              and\n              <a href=\"https://appflowy.io/privacy/app\" style=\"color: #64748b\">Privacy Policy</a>.\n            </div>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%;\"></div>\n          </td>\n        </tr>\n        <tr>\n          <td style=\"padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569\">\n            <p style=\"margin: 0 0 16px; cursor: pointer; text-transform: uppercase\">\n              <a href=\"https://appflowy.io\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png\" width=\"150px\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\" alt=\"\">\n              </a>\n            </p>\n            <p style=\"margin: 0; font-size: 14px; font-weight: 500; color: #000;\">\n              Bring projects, knowledge, and teams together with the power of AI.\n            </p>\n            <p style=\"cursor: default\">\n              <a href=\"https://twitter.com/appflowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n            </p>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/confirmation.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>New sign up for AppFlowy</title>\n</head>\n<body style=\"margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"New sign up for AppFlowy\" lang=\"en\">\n    <div style=\"display: none\">\n      To login to AppFlowy, follow this link {{ .ConfirmationURL }}\n    </div>\n    <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#eeeefc\" style=\"\n      font-family:\n        Google Sans,\n        Robot,\n        Arial,\n        sans-serif;\n      padding: 24px;\n      color: #000000;\n    \" role=\"presentation\">\n      <tr>\n        <td align=\"center\" valign=\"top\">\n          <table width=\"600\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#ffffff\" style=\"border-radius: 12px; padding: 32px; margin: 0 auto\" role=\"presentation\">\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy.png\" width=\"32\" height=\"32\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0; display: block; margin: 0 auto\" alt=\"\">\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 8px\">\n                <h1 style=\"\n                  font-size: 24px;\n                  font-weight: bold;\n                  margin: 0;\n                  color: #000000;\n                \">\n                  Login for AppFlowy\n                </h1>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <p style=\"font-size: 16px; line-height: 1.5; margin: 0\">\n                  We have received a request to confirm your AppFlowy account.\n                </p>\n                <p style=\"font-size: 16px; line-height: 1.5; margin: 2px 0 0\">\n                  You can log in using either of the following options:\n                </p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 12px\">\n                <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#f8faff\" style=\"border-radius: 20px; padding: 24px 20px\" role=\"presentation\">\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px\">\n                      <h2 style=\"\n                        font-size: 16px;\n                        font-weight: bold;\n                        margin: 0;\n                        color: #000000;\n                      \">\n                        Option 1: Magic Link (Fast &amp; Easy)\n                      </h2>\n                      <p style=\"\n                        font-size: 14px;\n                        color: #6f748c;\n                        margin: 10px 0 0;\n                      \">\n                        Click the button or link below to log in instantly\n                      </p>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px\">\n                      <table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 0 auto\" role=\"presentation\">\n                        <tr>\n                          <td align=\"center\" bgcolor=\"#9327ff\" style=\"border-radius: 10px; padding: 10px 16px\">\n                            <a href=\"{{ .ConfirmationURL }}\" style=\"\n                              color: #ffffff;\n                              font-weight: 600;\n                              font-size: 14px;\n                              text-decoration: none;\n                              display: inline-block;\n                            \">Login to AppFlowy</a>\n                          </td>\n                        </tr>\n                      </table>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\">\n                      <p style=\"\n                        padding-bottom: 16px;\n                        font-size: 12px;\n                        color: #6f748c;\n                        margin: 0;\n                      \">\n                        Or paste this into your browser:\n                      </p>\n                      <p style=\"max-width: 384px; margin: 0\">\n                        <a href=\"{{ .ConfirmationURL }}\" style=\"\n                          font-size: 12px;\n                          text-decoration: none;\n                          text-align: center;\n                          color: #6f748c;\n                          font-weight: bold;\n                          margin: 0;\n                          word-break: break-all;\n                        \">\n                          {{ .ConfirmationURL }}\n                        </a>\n                      </p>\n                    </td>\n                  </tr>\n                </table>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#f8faff\" style=\"border-radius: 20px; padding: 24px 20px\" role=\"presentation\">\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px\">\n                      <h2 style=\"\n                        font-size: 16px;\n                        font-weight: bold;\n                        margin: 0;\n                        color: #000000;\n                      \">\n                        Option 2: One-Time Password (OTP)\n                      </h2>\n                      <p style=\"\n                        font-size: 14px;\n                        color: #6f748c;\n                        margin: 10px 0 0;\n                      \">\n                        Prefer to enter a code instead? Use the one-time code\n                        below\n                      </p>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\">\n                      <span style=\"\n                        background-color: #ffffff;\n                        padding: 8px;\n                        border-radius: 6px;\n                        font-weight: 600;\n                        font-size: 16px;\n                        display: inline-block;\n                      \">{{ .Token }}</span>\n                    </td>\n                  </tr>\n                </table>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  This code and magic link will expire in 5 minutes for security\n                  reasons.\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  If you didn't initiate this login, you can safely ignore this\n                  email. No action is needed.\n                </p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\">\n                <p style=\"\n                  border-top: 1px solid #e4e8f5;\n                  padding-top: 24px;\n                  font-size: 12px;\n                  color: #6f748c;\n                  margin: 0 0 15px;\n                \">\n                  Bring projects, knowledge, and teams together with the power of\n                  AI.\n                </p>\n                <p style=\"margin: 0 0 15px\">\n                  <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" height=\"20\" alt=\"Discord\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" height=\"20\" alt=\"GitHub\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" height=\"20\" alt=\"Reddit\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://twitter.com/appflowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" height=\"20\" alt=\"Twitter\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.youtube.com/@AppFlowyHQ\" style=\"text-decoration: none; display: inline-block\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/youtube.png\" width=\"20\" height=\"20\" alt=\"Youtube\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0 0 10px\">\n                  Copyright © 2025, AppFlowy Inc.\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  Need Help?\n                  <a href=\"mailto:support@appflowy.io\" style=\"color: #6f748c\">support@appflowy.io</a>\n                </p>\n              </td>\n            </tr>\n          </table>\n        </td>\n      </tr>\n    </table>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/import_data_fail.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Workspace Import Failed</title>\n  <style>\n    @media (max-width: 600px) {\n      .sm-px-4 {\n        padding-left: 16px !important;\n        padding-right: 16px !important\n      }\n      .sm-py-12 {\n        padding-top: 48px !important;\n        padding-bottom: 48px !important\n      }\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div style=\"display: none\">\n    There was an issue with your workspace import\n    &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847;\n  </div>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Workspace Import Failed\" lang=\"en\">\n    <div class=\"sm-px-4 sm-py-12\" style=\"background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 622px; max-width: 100%; text-align: center\">\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px\">\n              <span style=\"font-size: 30px; font-weight: 700\">Notion Import Failed</span>\n            </p>\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px;\">\n              <span style=\"color: #fb006d\">{{ error }}</span>\n            </p>\n            <div style=\"margin-left: auto; margin-right: auto; width: 70%; text-align: center; font-size: 14px; line-height: 18px; color: #64748b\">\n              Join our Discord <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"color: #9327ff\">server</a> to get quick help\n              or <a href=\"https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose\" style=\"color: #9327ff;\">\n                report</a> the issue on GitHub\n            </div>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%\"></div>\n          </td>\n        </tr>\n        <tr>\n          <td style=\"padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569\">\n            <p style=\"margin: 0 0 16px; cursor: pointer; text-transform: uppercase\">\n              <a href=\"https://appflowy.io\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png\" width=\"150px\" style=\"max-width: 100%; vertical-align: middle; line-height: 1\" alt=\"\">\n              </a>\n            </p>\n            <p style=\"margin: 0; font-size: 14px; font-weight: 500; color: #000\">\n              Bring projects, knowledge, and teams together with the power of AI.\n            </p>\n            <p style=\"cursor: default\">\n              <a href=\"https://twitter.com/appflowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n            </p>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/import_data_success.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Workspace Import Success</title>\n  <style>\n    .hover-opacity-90:hover {\n      opacity: 0.9 !important\n    }\n    @media (max-width: 600px) {\n      .sm-px-4 {\n        padding-left: 16px !important;\n        padding-right: 16px !important\n      }\n      .sm-py-12 {\n        padding-top: 48px !important;\n        padding-bottom: 48px !important\n      }\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div style=\"display: none\">\n    Your workspace import was successful\n    &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847;\n  </div>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Workspace Import Success\" lang=\"en\">\n    <div class=\"sm-px-4 sm-py-12\" style=\"background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 582px; max-width: 100%\">\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px\">\n              <span style=\"font-size: 30px; font-weight: 700\">Notion Import Complete</span>\n            </p>\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px;\">\n              <span>Your Notion data has been successfully imported into</span>\n            </p>\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px;\">\n              <span style=\"font-size: 30px; font-weight: 700;\">{{ workspace_name }}</span>\n            </p>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%\"></div>\n            <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n              <tr>\n                <td style=\"width: 60px\">\n                  <div style=\"margin-right: 8px; height: 60px; width: 60px; overflow: hidden; border-radius: 16px; background-color: #fff; padding: 8px; border: 2px solid black\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy.png\" width=\"100%\" height=\"100%\" alt=\"{{ workspace_name }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; object-fit: cover\">\n                  </div>\n                </td>\n                <td>\n                  <div style=\"margin-bottom: 8px; font-weight: 700\">\n                    {{ workspace_name }}\n                  </div>\n                  <div style=\"font-size: 14px; color: #64748b\"> 1 member</div>\n                </td>\n              </tr>\n            </table>\n            <div style=\"text-align: center;\">\n              <a href=\"https://appflowy.io/download\" class=\"hover-opacity-90\" target=\"_blank\" style=\"margin-top: 32px; margin-bottom: 32px; display: inline-block; width: 60%; cursor: pointer; border-radius: 16px; padding: 16px 24px; color: #f8fafc; text-decoration: none; background-color: #9327ff; font-size: 20px; font-weight: 400; line-height: 20px\">\n                <!--[if mso]>\n      <i style=\"mso-font-width: 150%; mso-text-raise: 30px\" hidden>&amp;emsp;</i>\n    <![endif]-->\n                <span style=\"mso-text-raise: 16px\">\n            <div style=\"font-size: 24px; font-weight: 500\">\n              Download\n            </div>\n          </span>\n                <!--[if mso]>\n      <i hidden=\"\" style=\"mso-font-width: 150%;\">&amp;emsp;&amp;#8203;</i>\n    <![endif]-->\n              </a>\n            </div>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%;\"></div>\n          </td>\n        </tr>\n        <tr>\n          <td style=\"padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569\">\n            <p style=\"margin: 0 0 16px; cursor: pointer; text-transform: uppercase\">\n              <a href=\"https://appflowy.io\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png\" width=\"150px\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\" alt=\"\">\n              </a>\n            </p>\n            <p style=\"margin: 0; font-size: 14px; font-weight: 500; color: #000;\">\n              Bring projects, knowledge, and teams together with the power of AI.\n            </p>\n            <p style=\"cursor: default\">\n              <a href=\"https://twitter.com/appflowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n            </p>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/magic_link.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Login for AppFlowy</title>\n</head>\n<body style=\"margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Login for AppFlowy\" lang=\"en\">\n    <div style=\"display: none\">\n      To login to AppFlowy, follow this link {{ .ConfirmationURL }}\n    </div>\n    <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#eeeefc\" style=\"font-family: Google Sans, Robot, Arial, sans-serif; padding: 24px; color: #000000;\" role=\"presentation\">\n      <tr>\n        <td align=\"center\" valign=\"top\">\n          <table width=\"600\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#ffffff\" style=\"border-radius: 12px; padding: 32px; margin: 0 auto;\" role=\"presentation\">\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy.png\" width=\"32\" height=\"32\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0; display: block; margin: 0 auto\" alt=\"\">\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 8px;\">\n                <h1 style=\"font-size: 24px; font-weight: bold; margin: 0; color: #000000;\">Login for AppFlowy</h1>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px;\">\n                <p style=\"font-size: 16px; line-height: 1.5; margin: 0;\">We received a request to log in to your AppFlowy account.</p>\n                <p style=\"font-size: 16px; line-height: 1.5; margin: 2px 0 0;\">You can log in using either of the following options:</p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 12px;\">\n                <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#f8faff\" style=\"border-radius: 20px; padding: 24px 20px;\" role=\"presentation\">\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px;\">\n                      <h2 style=\"font-size: 16px; font-weight: bold; margin: 0; color: #000000;\">Option 1: Magic Link (Fast &amp; Easy)</h2>\n                      <p style=\"font-size: 14px; color: #6F748C; margin: 10px 0 0;\">Click the button or link below to log in instantly</p>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px;\">\n                      <table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 0 auto;\" role=\"presentation\">\n                        <tr>\n                          <td align=\"center\" bgcolor=\"#9327ff\" style=\"border-radius: 10px; padding: 10px 16px;\">\n                            <a href=\"{{ .ConfirmationURL }}\" style=\"color: #ffffff; font-weight: 600; font-size: 14px; text-decoration: none; display: inline-block;\">Login to AppFlowy</a>\n                          </td>\n                        </tr>\n                      </table>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\">\n                      <p style=\"padding-bottom: 16px; font-size: 12px; color: #6F748C; margin: 0;\">Or paste this into your browser:</p>\n                      <p style=\"max-width: 384px;margin: 0;\">\n                        <a href=\"{{ .ConfirmationURL }}\" style=\"font-size: 12px; text-decoration: none; text-align: center; color: #6F748C; font-weight: bold; margin: 0; word-break: break-all;\">\n                          {{ .ConfirmationURL }}\n                        </a>\n                      </p>\n                    </td>\n                  </tr>\n                </table>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px;\">\n                <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#f8faff\" style=\"border-radius: 20px; padding: 24px 20px;\" role=\"presentation\">\n                  <tr>\n                    <td align=\"center\" style=\"padding-bottom: 16px;\">\n                      <h2 style=\"font-size: 16px; font-weight: bold; margin: 0; color: #000000;\">Option 2: One-Time Password (OTP)</h2>\n                      <p style=\"font-size: 14px; color: #6F748C; margin: 10px 0 0;\">Prefer to enter a code instead? Use the one-time code below</p>\n                    </td>\n                  </tr>\n                  <tr>\n                    <td align=\"center\">\n                      <span style=\"background-color: #ffffff; padding: 8px; border-radius: 6px; font-weight: 600; font-size: 16px; display: inline-block;\">{{ .Token }}</span>\n                    </td>\n                  </tr>\n                </table>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px;\">\n                <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">This code and magic link will expire in 5 minutes for security reasons.</p>\n                <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">If you didn't initiate this login, you can safely ignore this email. No action is needed.</p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\">\n                <p style=\"border-top: 1px solid #e4e8f5; padding-top: 24px; font-size: 12px; color: #6F748C; margin: 0 0 15px;\">Bring projects, knowledge, and teams together with the power of AI.</p>\n                <p style=\"margin: 0 0 15px;\">\n                  <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" height=\"20\" alt=\"Discord\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" height=\"20\" alt=\"GitHub\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" height=\"20\" alt=\"Reddit\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://twitter.com/appflowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" height=\"20\" alt=\"Twitter\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.youtube.com/@AppFlowyHQ\" style=\"text-decoration: none; display: inline-block;\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/youtube.png\" width=\"20\" height=\"20\" alt=\"Youtube\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                </p>\n                <p style=\"font-size: 12px; color: #6F748C; margin: 0 0 10px;\">Copyright © 2025, AppFlowy Inc.</p>\n                <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">\n                  Need Help? <a href=\"mailto:support@appflowy.io\" style=\"color: #6F748C;\">support@appflowy.io</a>\n                </p>\n              </td>\n            </tr>\n          </table>\n        </td>\n      </tr>\n    </table>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/page_mention_notification.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <style>\n    .hover-opacity-90:hover {\n      opacity: 0.9 !important\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #EEEEFC; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"\" lang=\"en\">\n    <div style=\"background-color: #EEEEFC; padding: 48px 16px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 600px; max-width: 100%; border-radius: 16px; background-color: #fff; padding: 48px 64px; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)\">\n            <p style=\"margin-bottom: 24px; width: 100%; text-align: center\">\n              <img src=\"{{ mentioner_icon_url }}\" width=\"64\" height=\"64\" alt=\"AppFlowy\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border-radius: 9999px\">\n            </p>\n            <p style=\"margin-bottom: 32px; width: 100%; text-align: center; font-size: 32px\">\n              <span style=\"font-size: 30px; font-weight: 700\">{{ mentioner_name }}</span>\n              <span>has mentioned you in </span>\n              <span style=\"font-size: 30px; font-weight: 700;\">{{ mentioned_page_name }}</span>\n            </p>\n            <p style=\"width: 100%; text-align: center; font-size: 16px; color: var(--Text-secondary, #6f748c)\">\n              {{ mentioned_at }}\n            </p>\n            <p style=\"margin-bottom: 32px; width: 100%; text-align: center; font-size: 16px; color: var(--Text-secondary, #6f748c);\">\n              {{ workspace_name }} / ... / {{ mentioned_page_name }}\n            </p>\n            <div style=\"margin-bottom: 32px; text-align: center;\">\n              <div style=\"text-align: center;\">\n                <a href=\"{{ launch_workspace_url }}\" class=\"hover-opacity-90\" style=\"display: inline-block; cursor: pointer; border-radius: 8px; color: #f8fafc; text-decoration: none; padding: 12px 36px; font-weight: 500; background-color: #9327FF; font-size: 16px; line-height: 20px\" target=\"_blank\">\n                  <!--[if mso]>\n      <i style=\"mso-font-width: 150%; mso-text-raise: 30px\" hidden>&amp;emsp;</i>\n    <![endif]-->\n                  <span style=\"mso-text-raise: 16px\">\n              Go to page\n            </span>\n                  <!--[if mso]>\n      <i hidden=\"\" style=\"mso-font-width: 150%;\">&amp;emsp;&amp;#8203;</i>\n    <![endif]-->\n                </a>\n              </div>\n            </div>\n            <hr style=\"margin-top: 32px; margin-bottom: 32px; border-top-width: 1px; border-color: #f3f4f6; opacity: 0.4\">\n            <div style=\"text-align: center;\">\n              <p style=\"margin: 0 0 24px; font-size: 14px; color: var(--Text-secondary, #6f748c)\">\n                Bring projects, knowledge, and teams together with the power of\n                AI.\n              </p>\n              <p style=\"margin: 0 0 16px\">\n                <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-left: 8px; margin-right: 8px; text-decoration: none\">\n                  <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"24\" height=\"24\" alt=\"Discord\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n                </a>\n                <a href=\" https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-left: 8px; margin-right: 8px; color: #4b5563; text-decoration: none\">\n                  <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"24\" height=\"24\" alt=\"GitHub\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n                </a>\n                <a href=\" https://www.reddit.com/r/AppFlowy\" style=\"margin-left: 8px; margin-right: 8px; color: #4b5563; text-decoration: none;\">\n                  <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"24\" height=\"24\" alt=\"Reddit\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n                </a>\n                <a href=\" https://twitter.com/appflowy\" style=\"margin-left: 8px; margin-right: 8px; text-decoration: none;\">\n                  <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"24\" height=\"24\" alt=\"Twitter\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n                </a>\n                <a href=\" https://www.youtube.com/@appflowy\" style=\"margin-left: 8px; margin-right: 8px; color: #4b5563; text-decoration: none;\">\n                  <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/youtube.png\" width=\"24\" height=\"24\" alt=\"YouTube\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n                </a>\n              </p>\n              <p style=\"margin: 0; color: var(--Text-secondary, #6f748c); font-size: 12px; font-family: SF Pro Text,\n                  Arial,\n                  sans-serif; font-weight: 400; line-height: 18px; letter-spacing: 0.1px; word-wrap: break-word;\">\n                Copyright © 2025, AppFlowy Inc.\n              </p>\n              <p style=\"margin: 0; color: var(--Text-secondary, #6f748c); font-size: 12px; font-family: SF Pro Text,\n                  Arial,\n                  sans-serif; font-weight: 400; line-height: 18px; letter-spacing: 0.1px; word-wrap: break-word;\">\n                Need Help?\n                <a href=\"mailto:support@appflowy.io\" style=\"\n                  color: var(--Text-secondary, #6f748c);\n                  font-size: 12px;\n                  font-family:\n                    SF Pro Text,\n                    Arial,\n                    sans-serif;\n                  font-weight: 400;\n                  text-decoration: underline;\n                \">\n                  support@appflowy.io\n                </a>\n              </p> <span style=\"\n                color: transparent;\n                font-size: 1px;\n                line-height: 1px;\n                display: block;\n              \">\n              undefined\n            </span>\n            </div>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/recovery.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>AppFlowy Password Recovery</title>\n</head>\n<body style=\"margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"AppFlowy Password Recovery\" lang=\"en\">\n    <div style=\"display: none\">\n      To reset your password, enter the code below in the app:\n    </div>\n    <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#eeeefc\" style=\"\n      font-family:\n        Google Sans,\n        Robot,\n        Arial,\n        sans-serif;\n      padding: 24px;\n      color: #000000;\n    \" role=\"presentation\">\n      <tr>\n        <td align=\"center\" valign=\"top\">\n          <table width=\"600\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#ffffff\" style=\"border-radius: 12px; padding: 32px; margin: 0 auto\" role=\"presentation\">\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy.png\" width=\"32\" height=\"32\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0; display: block; margin: 0 auto\" alt=\"\">\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 8px\">\n                <h1 style=\"\n                  font-size: 24px;\n                  font-weight: bold;\n                  margin: 0;\n                  color: #000000;\n                \">\n                  Reset your password\n                </h1>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <p style=\"font-size: 16px; line-height: 1.5; margin: 0\">\n                  Someone recently requested a password reset for your AppFlowy\n                  account. If this was you, use the following verification code.\n                </p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <span style=\"\n                  background-color: #e4e8f5;\n                  padding: 8px;\n                  border-radius: 6px;\n                  font-weight: 600;\n                  font-size: 16px;\n                  display: inline-block;\n                \">{{ .Token }}</span>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\" style=\"padding-bottom: 24px\">\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  This code will expire in 5 minutes for security reasons.\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  If you didn't initiate this recovery, you can safely ignore this\n                  email. No action is needed.\n                </p>\n              </td>\n            </tr>\n            <tr>\n              <td align=\"center\">\n                <p style=\"\n                  border-top: 1px solid #e4e8f5;\n                  padding-top: 24px;\n                  font-size: 12px;\n                  color: #6f748c;\n                  margin: 0 0 15px;\n                \">\n                  Bring projects, knowledge, and teams together with the power of\n                  AI.\n                </p>\n                <p style=\"margin: 0 0 15px\">\n                  <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" height=\"20\" alt=\"Discord\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" height=\"20\" alt=\"GitHub\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" height=\"20\" alt=\"Reddit\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://twitter.com/appflowy\" style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" height=\"20\" alt=\"Twitter\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                  <a href=\"https://www.youtube.com/@AppFlowyHQ\" style=\"text-decoration: none; display: inline-block\">\n                    <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/youtube.png\" width=\"20\" height=\"20\" alt=\"Youtube\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; border: 0;\">\n                  </a>\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0 0 10px\">\n                  Copyright © 2025, AppFlowy Inc.\n                </p>\n                <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                  Need Help?\n                  <a href=\"mailto:support@appflowy.io\" style=\"color: #6f748c\">support@appflowy.io</a>\n                </p>\n              </td>\n            </tr>\n          </table>\n        </td>\n      </tr>\n    </table>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/build_production/workspace_invitation.html",
    "content": "<!DOCTYPE>\n<html lang=\"en\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <title>Confirm to join the workspace</title>\n  <style>\n    .hover-opacity-90:hover {\n      opacity: 0.9 !important\n    }\n    @media (max-width: 600px) {\n      .sm-px-4 {\n        padding-left: 16px !important;\n        padding-right: 16px !important\n      }\n      .sm-py-12 {\n        padding-top: 48px !important;\n        padding-bottom: 48px !important\n      }\n    }\n  </style>\n</head>\n<body style=\"margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word\">\n  <div style=\"display: none\">\n    Please confirm your email address to join the workspace.\n    &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847; &amp;#8199;&amp;#65279;&amp;#847;\n  </div>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"Confirm to join the workspace\" lang=\"en\">\n    <div class=\"sm-px-4 sm-py-12\" style=\"background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000\">\n      <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n        <tr>\n          <td style=\"width: 552px; max-width: 100%\">\n            <div style=\"width: 100%; text-align: center\">\n              <img src=\"{{ user_icon_url }}\" width=\"48px\" height=\"48px\" alt=\"{{ username }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; border-radius: 9999px; object-fit: cover\">\n            </div>\n            <p style=\"width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px\">\n              <span style=\"font-size: 30px; font-weight: 700\">{{ username }}</span>\n              <span>invited you to </span>\n              <span style=\"font-size: 30px; font-weight: 700;\">{{ workspace_name }}</span>\n            </p>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%\"></div>\n            <table align=\"center\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\">\n              <tr>\n                <td style=\"width: 60px\">\n                  <div style=\"margin-right: 8px; height: 60px; width: 60px; overflow: hidden; border-radius: 16px; background-color: #fff; border: 2px solid black\">\n                    <img src=\"{{ workspace_icon_url }}\" width=\"100%\" height=\"100%\" alt=\"{{ workspace_name }}\" style=\"max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; object-fit: cover;\">\n                  </div>\n                </td>\n                <td>\n                  <div style=\"margin-bottom: 8px; font-weight: 700\">\n                    {{ workspace_name }}\n                  </div>\n                  <div style=\"font-size: 14px; color: #64748b\"> {{ workspace_member_count }} members</div>\n                </td>\n              </tr>\n            </table>\n            <div style=\"text-align: center;\">\n              <a href=\"{{ accept_url }}\" class=\"hover-opacity-90\" style=\"margin-top: 32px; margin-bottom: 32px; display: inline-block; width: 60%; cursor: pointer; border-radius: 16px; padding: 16px 24px; color: #f8fafc; text-decoration: none; background-color: #9327ff; font-size: 20px; font-weight: 400; line-height: 20px\">\n                <!--[if mso]>\n      <i style=\"mso-font-width: 150%; mso-text-raise: 30px\" hidden>&amp;emsp;</i>\n    <![endif]-->\n                <span style=\"mso-text-raise: 16px\">\n                        <div style=\"font-size: 24px; font-weight: 500\">\n                            Join workspace\n                            <div style=\"font-size: 12px\">require v0.5.6+ to continue</div>\n                        </div>\n                    </span>\n                <!--[if mso]>\n      <i hidden=\"\" style=\"mso-font-width: 150%;\">&amp;emsp;&amp;#8203;</i>\n    <![endif]-->\n              </a>\n            </div>\n            <div style=\"margin-left: auto; margin-right: auto; width: 70%; text-align: center; font-size: 14px; line-height: 18px; color: #64748b\">\n              By clicking \"Join workspace\" above, you confirm that you have read, understood, and agreed to\n              AppFlowy's <a href=\"https://appflowy.io/terms/app\" style=\"color: #64748b;\">Terms &amp;\n                Conditions</a>\n              and <a href=\"https://appflowy.io/privacy/app\" style=\"color: #64748b;\">Privacy\n                Policy</a>.\n            </div>\n            <div role=\"separator\" style=\"background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%;\"></div>\n          </td>\n        </tr>\n        <tr>\n          <td style=\"padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569\">\n            <p style=\"margin: 0 0 16px; cursor: pointer; text-transform: uppercase\">\n              <a href=\"https://appflowy.io\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png\" width=\"150px\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\" alt=\"\">\n              </a>\n            </p>\n            <p style=\"margin: 0; font-size: 14px; font-weight: 500; color: #000;\">\n              Bring projects, knowledge, and teams together with the power of AI.\n            </p>\n            <p style=\"cursor: default\">\n              <a href=\"https://twitter.com/appflowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n              <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"margin-right: 16px; color: #4338ca; text-decoration: none;\">\n                <img src=\"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png\" width=\"20\" alt=\"Maizzle\" style=\"max-width: 100%; vertical-align: middle; line-height: 1;\">\n              </a>\n            </p>\n          </td>\n        </tr>\n      </table>\n    </div>\n  </div>\n</body>\n</html>"
  },
  {
    "path": "assets/mailer_templates/confirmation.html",
    "content": "<!DOCTYPE html><html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\"><head>\n<!--[if gte mso 15]>\n<xml>\n<o:OfficeDocumentSettings>\n<o:AllowPNG/>\n<o:PixelsPerInch>96</o:PixelsPerInch>\n</o:OfficeDocumentSettings>\n</xml>\n<![endif]-->\n<meta charset=\"UTF-8\"/>\n<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n<title>Email Confirmation</title>\n<style>          img{-ms-interpolation-mode:bicubic;}\n          table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;}\n          .mceStandardButton, .mceStandardButton td, .mceStandardButton td a{mso-hide:all !important;}\n          p, a, li, td, blockquote{mso-line-height-rule:exactly;}\n          p, a, li, td, body, table, blockquote{-ms-text-size-adjust:100%; -webkit-text-size-adjust:100%;}\n          @media only screen and (max-width: 480px){\n            body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;}\n          }\n          .mcnPreviewText{display: none !important;}\n          .bodyCell{margin:0 auto; padding:0; width:100%;}\n          .ExternalClass, .ExternalClass p, .ExternalClass td, .ExternalClass div, .ExternalClass span, .ExternalClass font{line-height:100%;}\n          .ReadMsgBody{width:100%;} .ExternalClass{width:100%;}\n          a[x-apple-data-detectors]{color:inherit !important; text-decoration:none !important; font-size:inherit !important; font-family:inherit !important; font-weight:inherit !important; line-height:inherit !important;}\n            body{height:100%; margin:0; padding:0; width:100%; background: #ffffff;}\n            p{margin:0; padding:0;}\n            table{border-collapse:collapse;}\n            td, p, a{word-break:break-word;}\n            h1, h2, h3, h4, h5, h6{display:block; margin:0; padding:0;}\n            img, a img{border:0; height:auto; outline:none; text-decoration:none;}\n            a[href^=\"tel\"], a[href^=\"sms\"]{color:inherit; cursor:default; text-decoration:none;}\n            li p {margin: 0 !important;}\n            .ProseMirror a {\n                pointer-events: none;\n            }\n            @media only screen and (max-width: 480px){\n                body{width:100% !important; min-width:100% !important; }\n                body.mobile-native {\n                    -webkit-user-select: none; user-select: none; transition: transform 0.2s ease-in; transform-origin: top center;\n                }\n                body.mobile-native.selection-allowed a, body.mobile-native.selection-allowed .ProseMirror {\n                    user-select: auto;\n                    -webkit-user-select: auto;\n                }\n                colgroup{display: none;}\n                img{height: auto !important;}\n                .mceWidthContainer{max-width: 660px !important;}\n                .mceColumn{display: block !important; width: 100% !important;}\n                .mceColumn-forceSpan{display: table-cell !important; width: auto !important;}\n                .mceColumn-forceSpan .mceButton a{min-width:0 !important;}\n                .mceBlockContainer{padding-right:16px !important; padding-left:16px !important;}\n                .mceBlockContainerE2E{padding-right:0px; padding-left:0px;}\n                .mceSpacing-24{padding-right:16px !important; padding-left:16px !important;}\n                .mceImage, .mceLogo{width: 100% !important; height: auto !important;}\n                .mceFooterSection .mceText, .mceFooterSection .mceText p{font-size: 16px !important; line-height: 140% !important;}\n                .mceText, .mceText p{font-size: 16px !important; line-height: 140% !important;}\n                h1{font-size: 30px !important; line-height: 120% !important;}\n                h2{font-size: 26px !important; line-height: 120% !important;}\n                h3{font-size: 20px !important; line-height: 125% !important;}\n                h4{font-size: 18px !important; line-height: 125% !important;}\n            }\n            @media only screen and (max-width: 640px){\n                .mceClusterLayout td{padding: 4px !important;}\n            }\n            div[contenteditable=\"true\"] {outline: 0;}\n            .ProseMirror .empty-node, .ProseMirror:empty {position: relative;}\n            .ProseMirror .empty-node::before, .ProseMirror:empty::before {\n                position: absolute;\n                left: 0;\n                right: 0;\n                color: rgba(0,0,0,0.2);\n                cursor: text;\n            }\n            .ProseMirror .empty-node:hover::before, .ProseMirror:empty:hover::before {\n                color: rgba(0,0,0,0.3);\n            }\n            .ProseMirror h1.empty-node:only-child::before,\n            .ProseMirror h2.empty-node:only-child::before,\n            .ProseMirror h3.empty-node:only-child::before,\n            .ProseMirror h4.empty-node:only-child::before {\n                content: 'Heading';\n            }\n            .ProseMirror p.empty-node:only-child::before, .ProseMirror:empty::before {\n                content: 'Start typing...';\n            }\n            a .ProseMirror p.empty-node::before, a .ProseMirror:empty::before {\n                content: '';\n            }\n            .mceText, .ProseMirror {\n                white-space: pre-wrap;\n            }\nbody, #bodyTable { background-color: rgb(244, 244, 244); }.mceText, .mceLabel { font-family: \"Helvetica Neue\", Helvetica, Arial, Verdana, sans-serif; }.mceText, .mceLabel { color: rgb(0, 0, 0); }.mceText h1 { margin-bottom: 0px; }.mceText p { margin-bottom: 0px; }.mceText label { margin-bottom: 0px; }.mceText input { margin-bottom: 0px; }.mceSpacing-24 .mceInput + .mceErrorMessage { margin-top: -12px; }.mceText h1 { margin-bottom: 0px; }.mceText p { margin-bottom: 0px; }.mceText label { margin-bottom: 0px; }.mceText input { margin-bottom: 0px; }.mceSpacing-12 .mceInput + .mceErrorMessage { margin-top: -6px; }.mceText h1 { margin-bottom: 0px; }.mceText p { margin-bottom: 0px; }.mceText label { margin-bottom: 0px; }.mceText input { margin-bottom: 0px; }.mceSpacing-48 .mceInput + .mceErrorMessage { margin-top: -24px; }.mceInput { background-color: transparent; border: 2px solid rgb(208, 208, 208); width: 60%; color: rgb(77, 77, 77); display: block; }.mceInput[type=\"radio\"], .mceInput[type=\"checkbox\"] { float: left; margin-right: 12px; display: inline; width: auto !important; }.mceLabel > .mceInput { margin-bottom: 0px; margin-top: 2px; }.mceLabel { display: block; }.mceText p { color: rgb(0, 0, 0); font-family: \"Helvetica Neue\", Helvetica, Arial, Verdana, sans-serif; font-size: 16px; font-weight: normal; line-height: 1.5; text-align: center; direction: ltr; }.mceText h1 { color: rgb(0, 0, 0); font-family: \"Helvetica Neue\", Helvetica, Arial, Verdana, sans-serif; font-size: 31px; font-weight: bold; line-height: 1.5; text-align: center; direction: ltr; }\n@media only screen and (max-width: 480px) {\n            .mceText p { font-size: 16px !important; line-height: 1.5 !important; }\n          }\n@media only screen and (max-width: 480px) {\n            .mceText h1 { font-size: 31px !important; line-height: 1.5 !important; }\n          }\n@media only screen and (max-width: 480px) {\n            .mceBlockContainer { padding-left: 16px !important; padding-right: 16px !important; }\n          }\n#dataBlockId-9 p, #dataBlockId-9 h1, #dataBlockId-9 h2, #dataBlockId-9 h3, #dataBlockId-9 h4, #dataBlockId-9 ul { text-align: center; }\n@media only screen and (max-width: 480px) {\n        .mobileClass-24 {padding-left: 12 !important;padding-top: 0 !important;padding-right: 12 !important;}.mobileClass-24 {padding-left: 12 !important;padding-top: 0 !important;padding-right: 12 !important;}.mobileClass-24 {padding-left: 12 !important;padding-top: 0 !important;padding-right: 12 !important;}\n      }</style>\n<script>!function(){function o(n,i){if(n&&i)for(var r in i)i.hasOwnProperty(r)&&(void 0===n[r]?n[r]=i[r]:n[r].constructor===Object&&i[r].constructor===Object?o(n[r],i[r]):n[r]=i[r])}try{var n=decodeURIComponent(\"%7B%0A%22ResourceTiming%22%3A%7B%0A%22comment%22%3A%20%22Clear%20RT%20Buffer%20on%20mPulse%20beacon%22%2C%0A%22clearOnBeacon%22%3A%20true%0A%7D%2C%0A%22AutoXHR%22%3A%7B%0A%22comment%22%3A%20%22Monitor%20XHRs%20requested%20using%20FETCH%22%2C%0A%22monitorFetch%22%3A%20true%2C%0A%22comment%22%3A%20%22Start%20Monitoring%20SPAs%20from%20Click%22%2C%0A%22spaStartFromClick%22%3A%20true%0A%7D%2C%0A%22PageParams%22%3A%7B%0A%22comment%22%3A%20%22Monitor%20all%20SPA%20XHRs%22%2C%0A%22spaXhr%22%3A%20%22all%22%0A%7D%0A%7D\");if(n.length>0&&window.JSON&&\"function\"==typeof window.JSON.parse){var i=JSON.parse(n);void 0!==window.BOOMR_config?o(window.BOOMR_config,i):window.BOOMR_config=i}}catch(r){window.console&&\"function\"==typeof window.console.error&&console.error(\"mPulse: Could not parse configuration\",r)}}();</script>\n                              <script>!function(a){var e=\"https://s.go-mpulse.net/boomerang/\",t=\"addEventListener\";if(\"True\"==\"True\")a.BOOMR_config=a.BOOMR_config||{},a.BOOMR_config.PageParams=a.BOOMR_config.PageParams||{},a.BOOMR_config.PageParams.pci=!0,e=\"https://s2.go-mpulse.net/boomerang/\";if(window.BOOMR_API_key=\"QAT5G-9HZLF-7EDMX-YMVCJ-QZJDA\",function(){function n(e){a.BOOMR_onload=e&&e.timeStamp||(new Date).getTime()}if(!a.BOOMR||!a.BOOMR.version&&!a.BOOMR.snippetExecuted){a.BOOMR=a.BOOMR||{},a.BOOMR.snippetExecuted=!0;var i,_,o,r=document.createElement(\"iframe\");if(a[t])a[t](\"load\",n,!1);else if(a.attachEvent)a.attachEvent(\"onload\",n);r.src=\"javascript:void(0)\",r.title=\"\",r.role=\"presentation\",(r.frameElement||r).style.cssText=\"width:0;height:0;border:0;display:none;\",o=document.getElementsByTagName(\"script\")[0],o.parentNode.insertBefore(r,o);try{_=r.contentWindow.document}catch(O){i=document.domain,r.src=\"javascript:var d=document.open();d.domain='\"+i+\"';void(0);\",_=r.contentWindow.document}_.open()._l=function(){var a=this.createElement(\"script\");if(i)this.domain=i;a.id=\"boomr-if-as\",a.src=e+\"QAT5G-9HZLF-7EDMX-YMVCJ-QZJDA\",BOOMR_lstart=(new Date).getTime(),this.body.appendChild(a)},_.write(\"<bo\"+'dy onload=\"document._l();\">'),_.close()}}(),\"400\".length>0)if(a&&\"performance\"in a&&a.performance&&\"function\"==typeof a.performance.setResourceTimingBufferSize)a.performance.setResourceTimingBufferSize(400);!function(){if(BOOMR=a.BOOMR||{},BOOMR.plugins=BOOMR.plugins||{},!BOOMR.plugins.AK){var e=\"\"==\"true\"?1:0,t=\"\",n=\"oqh7onyxe4czgzrce5oq-f-3264c7d33-clientnsv4-s.akamaihd.net\",i=\"false\"==\"true\"?2:1,_={\"ak.v\":\"37\",\"ak.cp\":\"641052\",\"ak.ai\":parseInt(\"493573\",10),\"ak.ol\":\"0\",\"ak.cr\":119,\"ak.ipv\":4,\"ak.proto\":\"h2\",\"ak.rid\":\"4065f34a\",\"ak.r\":47863,\"ak.a2\":e,\"ak.m\":\"x\",\"ak.n\":\"essl\",\"ak.bpcip\":\"116.15.247.0\",\"ak.cport\":53936,\"ak.gh\":\"23.54.158.72\",\"ak.quicv\":\"\",\"ak.tlsv\":\"tls1.3\",\"ak.0rtt\":\"\",\"ak.csrc\":\"-\",\"ak.acc\":\"\",\"ak.t\":\"1713514333\",\"ak.ak\":\"hOBiQwZUYzCg5VSAfCLimQ==CJVvw1SlOQmxWjbcqw5cLibL8Dtu3BSYkLgyAxy7tEU6HEqTq40xwPt6riTIKiy9mxbaQRxKGFnGMOgzDAgm2JuMr1a7swrOhomzql/VY9AjpoXBf8tC9xjczthI7oKKi2MyquytO9wkgUolnBJGbAXwSA8FhVaIORyx5g2dXOApWVnlrsydW0TgBmLVBdcFFuqSLlWTXEVeIaZ5MpyFUddcaC6OI2WnGx5im7Dh3BObDNBMa4BbkrfgpUmPnnYHT50cVtt3/DBon6wkuMefOGlQv+5gPsFzgdYI7b0yLgZCAMf5nU3wPRVf9z5qg4uSJ1SZsxQ5p5t4KD31SL5XnBxfpAdA9xzVKBKkOa4nMf0gm4QyHV7tOTpNgwK6QlACrc5xqotG71psgYgXTZ8thsNSvtdnum/Or25sJGSdDaw=\",\"ak.pv\":\"89\",\"ak.dpoabenc\":\"\",\"ak.tf\":i};if(\"\"!==t)_[\"ak.ruds\"]=t;var o={i:!1,av:function(e){var t=\"http.initiator\";if(e&&(!e[t]||\"spa_hard\"===e[t]))_[\"ak.feo\"]=void 0!==a.aFeoApplied?1:0,BOOMR.addVar(_)},rv:function(){var a=[\"ak.bpcip\",\"ak.cport\",\"ak.cr\",\"ak.csrc\",\"ak.gh\",\"ak.ipv\",\"ak.m\",\"ak.n\",\"ak.ol\",\"ak.proto\",\"ak.quicv\",\"ak.tlsv\",\"ak.0rtt\",\"ak.r\",\"ak.acc\",\"ak.t\",\"ak.tf\"];BOOMR.removeVar(a)}};BOOMR.plugins.AK={akVars:_,akDNSPreFetchDomain:n,init:function(){if(!o.i){var a=BOOMR.subscribe;a(\"before_beacon\",o.av,null,null),a(\"onbeacon\",o.rv,null,null),o.i=!0}return this},is_complete:function(){return!0}}}}()}(window);</script></head>\n<body>\n<center>\n<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" height=\"100%\" width=\"100%\" id=\"bodyTable\" style=\"background-color: rgb(244, 244, 244);\">\n<tbody><tr>\n<td class=\"bodyCell\" align=\"center\" valign=\"top\">\n<table id=\"root\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\"><tbody data-block-id=\"13\" class=\"mceWrapper\"><tr><td align=\"center\" valign=\"top\" class=\"mceWrapperOuter\"><!--[if (gte mso 9)|(IE)]><table align=\"center\" border=\"0\" cellspacing=\"0\" cellpadding=\"0\" width=\"660\" style=\"width:660px;\"><tr><td><![endif]--><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"max-width:660px\" role=\"presentation\"><tbody><tr><td style=\"background-color:#ffffff;background-position:center;background-repeat:no-repeat;background-size:cover\" class=\"mceWrapperInner\" valign=\"top\"><table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" data-block-id=\"12\"><tbody><tr class=\"mceRow\"><td style=\"background-position:center;background-repeat:no-repeat;background-size:cover\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\"><tbody><tr><td style=\"padding-top:0;padding-bottom:0\" class=\"mceColumn\" data-block-id=\"-10\" valign=\"top\" colspan=\"12\" width=\"100%\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\"><tbody><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:48px;padding-left:48px\" class=\"mceBlockContainer\" align=\"center\" valign=\"top\"><img data-block-id=\"2\" width=\"34.617283950617264\" height=\"auto\" style=\"width:34.617283950617264px;height:auto;max-width:34.617283950617264px !important;border:0;display:block\" alt=\"Logo\" src=\"https://mcusercontent.com/b4294d99430126e6773ddd0aa/images/f82a057d-7be3-4355-80a0-81318f333f32.png\" class=\"mceLogo\"/></td></tr><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:24px;padding-left:24px\" class=\"mceBlockContainer\" valign=\"top\"><div data-block-id=\"3\" class=\"mceText\" id=\"dataBlockId-3\" style=\"width:100%\"><h1>Log in to AppFlowy</h1><p><br/></p><p class=\"last-child\">Click the button below to securely log in or sign up. This magic link will expire in 5 minutes.</p></div></td></tr><tr><td style=\"background-color:transparent;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0\" class=\"mceBlockContainer\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"background-color:transparent\" role=\"presentation\" data-block-id=\"16\"><tbody><tr><td style=\"min-width:100%;border-top:20px solid transparent\" valign=\"top\"></td></tr></tbody></table></td></tr><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:24px;padding-left:24px\" class=\"mceBlockContainer\" align=\"center\" valign=\"top\"><table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" data-block-id=\"5\"><tbody><tr><!--[if !mso]><!--></tr><tr class=\"mceStandardButton\"><td style=\"background-color:#8427e0;border-radius:8px;text-align:center\" class=\"mceButton\" valign=\"top\"><a href=\"{{ .ConfirmationURL }}\" target=\"_blank\" style=\"background-color:#8427e0;border-radius:8px;border:2px solid #8427e0;color:#ffffff;display:block;font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;font-size:16px;font-weight:normal;font-style:normal;padding:16px 28px;text-decoration:none;min-width:30px;text-align:center;direction:ltr;letter-spacing:0px\">Log in / Sign up</a></td></tr><tr><!--<![endif]--></tr><tr>\n<!--[if mso]>\n<td align=\"center\">\n<v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\"\nxmlns:w=\"urn:schemas-microsoft-com:office:word\"\nhref=\"\"\nstyle=\"v-text-anchor:middle; width:173.22px; height:55px;\"\narcsize=\"5%\"\nstrokecolor=\"#8427e0\"\nstrokeweight=\"2px\"\nfillcolor=\"#8427e0\">\n<v:stroke dashstyle=\"solid\"/>\n<w:anchorlock />\n<center style=\"\ncolor: #ffffff;\ndisplay: block;\nfont-family: 'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;\nfont-size: 16;\nfont-style: normal;\nfont-weight: normal;\nletter-spacing: 0px;\ntext-decoration: none;\ntext-align: center;\ndirection: ltr;\"\n>\nLog in / Sign up\n</center>\n</v:roundrect>\n</td>\n<![endif]-->\n</tr></tbody></table></td></tr><tr><td style=\"background-color:transparent;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0\" class=\"mceBlockContainer\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"background-color:transparent\" role=\"presentation\" data-block-id=\"17\"><tbody><tr><td style=\"min-width:100%;border-top:20px solid transparent\" valign=\"top\"></td></tr></tbody></table></td></tr><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:24px;padding-left:24px\" class=\"mceBlockContainer\" valign=\"top\"><div data-block-id=\"15\" class=\"mceText\" id=\"dataBlockId-15\" style=\"width:100%\"><p class=\"last-child\">Confirming this request will securely log you in.</p></div></td></tr><tr><td style=\"background-color:transparent;padding-top:20px;padding-bottom:20px;padding-right:24px;padding-left:24px\" class=\"mceBlockContainer\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"background-color:transparent\" role=\"presentation\" data-block-id=\"6\"><tbody><tr><td style=\"min-width:100%;border-top:2px solid #8427e0\" valign=\"top\"></td></tr></tbody></table></td></tr><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:0;padding-left:0\" class=\"mceLayoutContainer\" valign=\"top\"><table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" data-block-id=\"7\"><tbody><tr class=\"mceRow\"><td style=\"background-position:center;background-repeat:no-repeat;background-size:cover\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"24\" width=\"100%\" role=\"presentation\"><tbody><tr><td style=\"margin-bottom:24px\" class=\"mceColumn\" data-block-id=\"-9\" valign=\"top\" colspan=\"12\" width=\"100%\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\"><tbody><tr><td align=\"center\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"\" role=\"presentation\" class=\"mceClusterLayout\" data-block-id=\"-8\"><tbody><tr><td style=\"padding-left:24px;padding-top:0;padding-right:24px\" data-breakpoint=\"24\" valign=\"top\" class=\"mobileClass-24\"><a href=\"https://twitter.com/appflowy\" style=\"display:block\" target=\"_blank\" data-block-id=\"-5\"><img width=\"24\" height=\"auto\" style=\"width:24px;height:auto;max-width:40px !important;border:0;display:block\" alt=\"Twitter icon\" src=\"https://cdn-images.mailchimp.com/icons/social-block-v3/block-icons-v3/twitter-filled-gray-40.png\" class=\"mceImage\"/></a></td><td style=\"padding-left:24px;padding-top:0;padding-right:24px\" data-breakpoint=\"24\" valign=\"top\" class=\"mobileClass-24\"><a href=\"https://forum.appflowy.io/\" style=\"display:block\" target=\"_blank\" data-block-id=\"-6\"><img width=\"24\" height=\"auto\" style=\"width:24px;height:auto;max-width:40px !important;border:0;display:block\" alt=\"Website icon\" src=\"https://cdn-images.mailchimp.com/icons/social-block-v3/block-icons-v3/website-filled-gray-40.png\" class=\"mceImage\"/></a></td><td style=\"padding-left:24px;padding-top:0;padding-right:24px\" data-breakpoint=\"24\" valign=\"top\" class=\"mobileClass-24\"><a href=\"mailto:support@appflowy.io\" style=\"display:block\" target=\"_blank\" data-block-id=\"-7\"><img width=\"24\" height=\"auto\" style=\"width:24px;height:auto;max-width:40px !important;border:0;display:block\" alt=\"Email icon\" src=\"https://cdn-images.mailchimp.com/icons/social-block-v3/block-icons-v3/email-filled-gray-40.png\" class=\"mceImage\"/></a></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr><tr><td style=\"padding-top:8px;padding-bottom:8px;padding-right:8px;padding-left:8px\" class=\"mceLayoutContainer\" valign=\"top\"><table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" data-block-id=\"11\" id=\"section_47639a3ef7838e89d24cf15d5661e922\" class=\"mceFooterSection\"><tbody><tr class=\"mceRow\"><td style=\"background-position:center;background-repeat:no-repeat;background-size:cover\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"12\" width=\"100%\" role=\"presentation\"><tbody><tr><td style=\"padding-top:0;padding-bottom:0;margin-bottom:12px\" class=\"mceColumn\" data-block-id=\"-3\" valign=\"top\" colspan=\"12\" width=\"100%\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\"><tbody><tr><td style=\"padding-top:12px;padding-bottom:12px;padding-right:16px;padding-left:16px\" class=\"mceBlockContainer\" align=\"center\" valign=\"top\"><div data-block-id=\"9\" class=\"mceText\" id=\"dataBlockId-9\" style=\"display:inline-block;width:100%\"><p class=\"last-child\"><span style=\"color:#797979;\"><span style=\"font-size: 12px\">If you didn’t request this email, you can safely ignore it.</span></span></p></div></td></tr><tr><td class=\"mceLayoutContainer\" align=\"center\" valign=\"top\"><table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" data-block-id=\"-2\"><tbody><tr class=\"mceRow\"><td style=\"background-position:center;background-repeat:no-repeat;background-size:cover;padding-top:0px;padding-bottom:0px\" valign=\"top\"><table border=\"0\" cellpadding=\"0\" cellspacing=\"24\" width=\"100%\" role=\"presentation\"><tbody></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table></td></tr></tbody></table><!--[if (gte mso 9)|(IE)]></td></tr></table><![endif]--></td></tr></tbody></table>\n</td>\n</tr>\n</tbody></table>\n</center>\n<script type=\"text/javascript\"  src=\"/TQILPX5gMchQ1nBO_BjW/1z5kcrz48b3D/fQEkCFEB/Tiw/dQ3ZzWEs\"></script></body></html>\n"
  },
  {
    "path": "deny.toml",
    "content": "[advisories]\nignore = [\n  \"RUSTSEC-2024-0384\",\n  \"RUSTSEC-2025-0012\",\n]\n"
  },
  {
    "path": "deploy.env",
    "content": "# =============================================================================\n# AppFlowy Cloud - Production Deployment Configuration\n# =============================================================================\n# This file is a template for docker compose deployment\n# Copy this file to .env and change the values as needed\n\n# Fully qualified domain name for the deployment. Replace localhost with your domain,\n# such as mydomain.com.\nFQDN=localhost\n\n# Change this to https if you are using TLS.\nSCHEME=http\n# Change this to wss if you are using TLS\nWS_SCHEME=ws\n\nAPPFLOWY_BASE_URL=${SCHEME}://${FQDN}\nAPPFLOWY_WEBSOCKET_BASE_URL=${WS_SCHEME}://${FQDN}/ws/v2\n\n# =============================================================================\n# 🗄️ DATABASE & CACHE: Core data infrastructure\n# =============================================================================\n\n# PostgreSQL Settings\nPOSTGRES_HOST=postgres\nPOSTGRES_USER=postgres\nPOSTGRES_PASSWORD=password\nPOSTGRES_PORT=5432\nPOSTGRES_DB=postgres\n\n# Redis Settings\nREDIS_HOST=redis\nREDIS_PORT=6379\n\n# =============================================================================\n# 🏗️ INFRASTRUCTURE SERVICES: Object storage and networking\n# =============================================================================\n\n# MinIO Configuration: S3-compatible object storage for file uploads and attachments\n# Docker service discovery: These values are used for container-to-container communication\n# MINIO_HOST refers to the Docker Compose service name, not an external domain/IP\n# Used by: AppFlowy Cloud, Worker services, AI service, and Admin Frontend\nMINIO_HOST=minio\nMINIO_PORT=9000\n\n# MinIO/AWS Credentials: Authentication keys for object storage access\n# Development: Uses MinIO's default credentials (minioadmin/minioadmin) for quick setup\n# Production: MUST be changed to secure, randomly generated credentials for security\n# These credentials are used across all services that access file storage\n# Security note: Default credentials are well-known and should never be used in production\nAWS_ACCESS_KEY=minioadmin\nAWS_SECRET=minioadmin\n\n# =============================================================================\n# ☁️ APPFLOWY SERVICES: Application service configuration\n# =============================================================================\n\n# AppFlowy Cloud Service Configuration\n# URL that connects to the gotrue docker container\nAPPFLOWY_GOTRUE_BASE_URL=http://gotrue:9999\n\n# URL that connects to the postgres docker container. If your password contains special characters,\n# instead of using ${POSTGRES_PASSWORD}, you will need to convert them into url encoded format.\n# For example, `p@ssword` will become `p%40ssword`.\nAPPFLOWY_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}\n\n# AppFlowy Service Configuration\n# Access Control System: Enables/disables permission-based access control\n# Controls workspace access, collaboration permissions, and realtime access restrictions\nAPPFLOWY_ACCESS_CONTROL=true\n\n# WebSocket Mailbox Configuration: Controls realtime server message handling capacity\n# Sets the maximum number of messages that can be queued in the WebSocket actor's mailbox\n# Higher values allow more concurrent WebSocket messages but use more memory\n# Lower values may cause message drops under high load but reduce memory usage\nAPPFLOWY_WEBSOCKET_MAILBOX_SIZE=6000\n\n# Database Connection Pool: Maximum number of concurrent PostgreSQL connections\n# Controls the size of the database connection pool for the AppFlowy Cloud service\n# PostgreSQL has a default limit of ~100 connections total (15 reserved for superuser)\n# Higher values improve concurrency but consume more database resources\n# Lower values reduce database load but may cause connection timeouts under load\nAPPFLOWY_DATABASE_MAX_CONNECTIONS=40\n\n# URL that connects to the redis docker container\nAPPFLOWY_REDIS_URI=redis://${REDIS_HOST}:${REDIS_PORT}\n\n# GoTrue database connection. If your password contains special characters,\n# instead of using ${POSTGRES_PASSWORD}, use the url encoded version.\n# For example, `p@ssword` will become `p%40ssword`\nGOTRUE_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}?search_path=auth\n\n# =============================================================================\n# 🔐 GOTRUE: Authentication service configuration\n# =============================================================================\n\n# GoTrue Admin Credentials\n# This user will be created when GoTrue starts successfully\n# You can use this user to login to the admin panel\nGOTRUE_ADMIN_EMAIL=admin@example.com\nGOTRUE_ADMIN_PASSWORD=password\n\n# JWT Configuration\n# Authentication key, change this and keep the key safe and secret\nGOTRUE_JWT_SECRET=hello456\n\n# Expiration time in seconds for the JWT token\nGOTRUE_JWT_EXP=604800\n\n# External URL where the GoTrue service is exposed\nAPI_EXTERNAL_URL=${APPFLOWY_BASE_URL}/gotrue\n\n# User Registration & Login Settings\n# User sign up will automatically be confirmed if this is set to true.\n# If you have OAuth2 set up or smtp configured, you can set this to false\n# to enforce email confirmation or OAuth2 login instead.\n# If you set this to false, you need to either set up SMTP\nGOTRUE_MAILER_AUTOCONFIRM=true\n\n# Set this to true if users can only join by invite\nGOTRUE_DISABLE_SIGNUP=false\n\n# Number of emails that can be sent per minute\nGOTRUE_RATE_LIMIT_EMAIL_SENT=100\n\n# Email Templates\n# Optional. You can provide a public http link (eg. github) to customize your magic link template.\n# Refer to https://github.com/supabase/auth?tab=readme-ov-file#configuration for details on how to create a custom email template.\nGOTRUE_MAILER_TEMPLATES_MAGIC_LINK=\n\n# =============================================================================\n# 📧 EMAIL CONFIGURATION: SMTP settings (optional but recommended for production)\n# =============================================================================\n\n# If you intend to use mail confirmation, you need to set the SMTP configuration below\n# You would then need to set GOTRUE_MAILER_AUTOCONFIRM=false\n# Check for logs in gotrue service if there are any issues with email confirmation\n# Note that smtps will be used for port 465, otherwise plain smtp with optional STARTTLS\nGOTRUE_SMTP_HOST=smtp.gmail.com\nGOTRUE_SMTP_PORT=465\nGOTRUE_SMTP_USER=email_sender@some_company.com\nGOTRUE_SMTP_PASS=email_sender_password\nGOTRUE_SMTP_ADMIN_EMAIL=comp_admin@some_company.com\n\n# AppFlowy Cloud Mailer\n# Note that smtps (TLS) is always required, even for ports other than 465\nAPPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com\nAPPFLOWY_MAILER_SMTP_PORT=465\nAPPFLOWY_MAILER_SMTP_USERNAME=email_sender@some_company.com\nAPPFLOWY_MAILER_SMTP_EMAIL=email_sender@some_company.com\nAPPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password\nAPPFLOWY_MAILER_SMTP_TLS_KIND=wrapper # \"none\" \"wrapper\" \"required\" \"opportunistic\"\n\n# =============================================================================\n# 🔑 OAUTH PROVIDERS: Third-party authentication (optional)\n# =============================================================================\n# Refer to this for details: https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/AUTHENTICATION.md\n\n# Google OAuth2\nGOTRUE_EXTERNAL_GOOGLE_ENABLED=false\nGOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=\nGOTRUE_EXTERNAL_GOOGLE_SECRET=\nGOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${API_EXTERNAL_URL}/callback\n\n# GitHub OAuth2\nGOTRUE_EXTERNAL_GITHUB_ENABLED=false\nGOTRUE_EXTERNAL_GITHUB_CLIENT_ID=\nGOTRUE_EXTERNAL_GITHUB_SECRET=\nGOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${API_EXTERNAL_URL}/callback\n\n# Discord OAuth2\nGOTRUE_EXTERNAL_DISCORD_ENABLED=false\nGOTRUE_EXTERNAL_DISCORD_CLIENT_ID=\nGOTRUE_EXTERNAL_DISCORD_SECRET=\nGOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${API_EXTERNAL_URL}/callback\n\n# Apple OAuth2\nGOTRUE_EXTERNAL_APPLE_ENABLED=false\nGOTRUE_EXTERNAL_APPLE_CLIENT_ID=\nGOTRUE_EXTERNAL_APPLE_SECRET=\nGOTRUE_EXTERNAL_APPLE_REDIRECT_URI=${API_EXTERNAL_URL}/callback\n\n# SAML 2.0. Refer to https://github.com/AppFlowy-IO/AppFlowy-Cloud/blob/main/doc/OKTA_SAML.md for example using Okta.\nGOTRUE_SAML_ENABLED=false\nGOTRUE_SAML_PRIVATE_KEY=\n\n# =============================================================================\n# 💾 FILE STORAGE: S3/MinIO configuration (required for file uploads)\n# =============================================================================\n\n# Storage Architecture Control: Determines the file storage backend for the entire system\n# Affects: User uploads, document attachments, collaboration snapshots, AI embeddings, import/export files\n# When true: Uses MinIO (S3-compatible) with path-style URLs and MinIO endpoint configuration\n# When false: Uses AWS S3 with region-based configuration and standard S3 URLs\n# Production options: Keep true for self-hosted MinIO, set false for AWS S3\nAPPFLOWY_S3_USE_MINIO=true\n\n# Bucket Management: Controls automatic bucket creation during AppFlowy startup\n# When true: AppFlowy automatically creates the storage bucket if it doesn't exist\n# When false: Assumes bucket exists and was created externally (recommended for production)\nAPPFLOWY_S3_CREATE_BUCKET=true\n\n# MinIO Endpoint Configuration: URL for MinIO API access\n# Uses Docker service discovery variables for container networking\n# Format combines MINIO_HOST and MINIO_PORT for internal service communication\n# Change this URL if using external MinIO instance or different networking setup\nAPPFLOWY_S3_MINIO_URL=http://${MINIO_HOST}:${MINIO_PORT}\n\n# Storage Authentication: Maps to the MinIO/AWS credentials defined above\n# These reference the AWS_ACCESS_KEY and AWS_SECRET variables for consistency\n# All AppFlowy services use these credentials to access the file storage backend\nAPPFLOWY_S3_ACCESS_KEY=${AWS_ACCESS_KEY}\nAPPFLOWY_S3_SECRET_KEY=${AWS_SECRET}\n\n# Storage Bucket: Default bucket name for all AppFlowy file storage\n# Contains: User files, document attachments, collaboration data, AI embeddings\n# Must exist in both MinIO and AWS S3 configurations\nAPPFLOWY_S3_BUCKET=appflowy\n\n# AWS S3 Configuration: Required only when APPFLOWY_S3_USE_MINIO=false\n# Uncomment and configure these settings when using AWS S3 instead of MinIO\n# APPFLOWY_S3_REGION=us-east-1\n\n# MinIO Presigned URL Endpoint: External URL for client-side file access (optional)\n# Enables direct file uploads/downloads from AppFlowy clients through presigned URLs\n# Set this to your public MinIO endpoint if using nginx proxy configuration\n# Format: Uses the external base URL with /minio-api path for API access\nAPPFLOWY_S3_PRESIGNED_URL_ENDPOINT=${APPFLOWY_BASE_URL}/minio-api\n\n# =============================================================================\n# 🤖 AI FEATURES: Optional AI capabilities (configure only if needed)\n# =============================================================================\n\n# AppFlowy AI\n# OpenAI API Authentication: Required API key for AI-powered features and semantic search\n# Controls access to OpenAI's embedding models (text-embedding-3-small) for document indexing\n# and ChatGPT models (gpt-4o-mini default) for search result summarization\n# When configured: Enables semantic document search, AI-powered search summaries, and document embeddings\n# When empty: AI features are disabled but core AppFlowy functionality remains fully operational\nAI_OPENAI_API_KEY=\n\n# If no summary model is provided, there will be no search summary when using AI search.\nAI_OPENAI_API_SUMMARY_MODEL=\n\n# Azure-hosted OpenAI API:\n# If you're using a self-hosted OpenAI API via Azure, leave AI_OPENAI_API_KEY empty\n# and set the following Azure-specific variables instead. If both are set, the standard OpenAI API will be used.\nAI_AZURE_OPENAI_API_KEY=\nAI_AZURE_OPENAI_API_BASE=\nAI_AZURE_OPENAI_API_VERSION=\n\n# AI Service Configuration (Docker container defaults)\nAI_SERVER_PORT=5001\nAI_SERVER_HOST=ai\nAI_DATABASE_URL=postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}\nAI_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}\nAI_APPFLOWY_BUCKET_NAME=${APPFLOWY_S3_BUCKET}\nAI_APPFLOWY_HOST=${APPFLOWY_BASE_URL}\nAI_MINIO_URL=http://${MINIO_HOST}:${MINIO_PORT}\n\n# Embedding Configuration\nAPPFLOWY_EMBEDDING_CHUNK_SIZE=2000\nAPPFLOWY_EMBEDDING_CHUNK_OVERLAP=200\n\n# =============================================================================\n# ⚙️ WORKER SERVICES: Background processing (good defaults for production)\n# =============================================================================\n\n# AppFlowy Indexer (for search functionality)\nAPPFLOWY_INDEXER_ENABLED=true\nAPPFLOWY_INDEXER_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}\nAPPFLOWY_INDEXER_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}\nAPPFLOWY_INDEXER_EMBEDDING_BUFFER_SIZE=5000\n\n# AppFlowy Collaboration Service Configuration:\n# Controls real-time collaboration behavior and performance\n# Multi-thread: Whether collaboration service uses multiple threads (can be true for production)\n# When deployed as standalone service, can be set to true for better performance\nAPPFLOWY_COLLABORATE_MULTI_THREAD=false\n\n# Remove batch size: Number of inactive collaboration groups to remove in a single batch (default: 100)\n# Higher values improve cleanup efficiency but may cause temporary blocking\nAPPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100\n\n# AppFlowy Worker Service\nAPPFLOWY_WORKER_REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}\nAPPFLOWY_WORKER_DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}\nAPPFLOWY_WORKER_DATABASE_NAME=${POSTGRES_DB}\n\n# =============================================================================\n# Real Time Transcription\n# =============================================================================\nASSEMBLYAI_API_KEY=\nASSEMBLYAI_API_BASE=https://api.assemblyai.com/v2\nASSEMBLYAI_STREAMING_API_BASE=https://streaming.assemblyai.com/v3\n\n# =============================================================================\n# 🌐 WEB FRONTEND: AppFlowy Web interface\n# =============================================================================\n\n# AppFlowy Web\n# If your AppFlowy Web is hosted on a different domain, update this variable to the correct domain\nAPPFLOWY_WEB_URL=${APPFLOWY_BASE_URL}\n\n# If you are running AppFlowy Web locally for development purpose, use the following value instead\n# APPFLOWY_WEB_URL=http://localhost:3000\n\n# =============================================================================\n# 🗄️ PGADMIN: Database Management Web Interface\n# =============================================================================\n\n# PgAdmin credentials for database management web UI\n# You can access pgadmin at http://your-host/pgadmin\n# Use the APPFLOWY_DATABASE_URL values when connecting to the database\nPGADMIN_DEFAULT_EMAIL=admin@example.com\nPGADMIN_DEFAULT_PASSWORD=password\n\n# =============================================================================\n# 🌐 NGINX: Reverse proxy and web server configuration\n# =============================================================================\n\n# NGINX Configuration\n# Optional, change this if you want to use custom ports to expose AppFlowy\nNGINX_PORT=80\nNGINX_TLS_PORT=443\n\n# =============================================================================\n# 🛠️ INFRASTRUCTURE: Networking, logging, and admin tools\n# =============================================================================\n\n# Log level for the appflowy-cloud service\nRUST_LOG=info\n\n# Cloudflare Tunnel (Advanced Networking)\n# Leave empty unless you're using Cloudflare tunnel for secure connections\nCLOUDFLARE_TUNNEL_TOKEN=\n\n# Enable AI tests in production environment (usually false)\n# Set to true only if you want to run AI-related tests in production\nAI_TEST_ENABLED=false\n\n"
  },
  {
    "path": "dev.env",
    "content": "# =============================================================================\n# AppFlowy Cloud - Development Environment Configuration\n# =============================================================================\n# This file is used to set the environment variables for local development\n# Copy this file to .env and change the values as needed\n\n# =============================================================================\n# 🗄️ DATABASE & CACHE: Core data infrastructure\n# =============================================================================\n\n# URL for sqlx\nDATABASE_URL=postgres://postgres:password@localhost:5432/postgres\n# Uncomment this to enable build without database\n# .sqlx files must be pregenerated\n# SQLX_OFFLINE=true\n\n# =============================================================================\n# ☁️ APPFLOWY SERVICES: Application service configuration\n# =============================================================================\n\n# GoTrue URL that the appflowy service will use to connect to gotrue\nAPPFLOWY_GOTRUE_BASE_URL=http://localhost:9999\nAPPFLOWY_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres\nAPPFLOWY_ACCESS_CONTROL=true\nAPPFLOWY_WEBSOCKET_MAILBOX_SIZE=6000\nAPPFLOWY_DATABASE_MAX_CONNECTIONS=40\nAPPFLOWY_DOCUMENT_CONTENT_SPLIT_LEN=8000\n\n# =============================================================================\n# 🔐 GOTRUE: Authentication service configuration\n# =============================================================================\n\n# GoTrue Admin Credentials\n# Admin user for accessing the admin panel\nGOTRUE_ADMIN_EMAIL=admin@example.com\nGOTRUE_ADMIN_PASSWORD=password\n\n# JWT Configuration\n# Authentication key, change this and keep the key safe and secret\nGOTRUE_JWT_SECRET=hello456\n# Expiration time in seconds for the JWT token\nGOTRUE_JWT_EXP=604800\n\n# External URL where the GoTrue service is exposed\n# The email verification link provided to users will redirect them to this specified host\n# For instance, if you're running your application locally using 'docker compose up -d',\n# you can set this value to 'http://localhost'\nAPI_EXTERNAL_URL=http://localhost:9999\n\n# GoTrue Database Connection\n# Database URL that gotrue will use\nGOTRUE_DATABASE_URL=postgres://postgres:password@postgres:5432/postgres?search_path=auth\n\n# User Registration & Login Settings\n# User sign up will automatically be confirmed if this is set to true\n# If you have OAuth2 set up or smtp configured, you can set this to false\n# to enforce email confirmation or OAuth2 login instead\nGOTRUE_MAILER_AUTOCONFIRM=false\n# Set this to true if users can only join by invite\nGOTRUE_DISABLE_SIGNUP=false\n\n# Email Rate Limiting\n# Number of emails that can be sent per minute\nGOTRUE_RATE_LIMIT_EMAIL_SENT=1000\n\n# =============================================================================\n# 📧 EMAIL CONFIGURATION: Optional (only configure if you need email features)\n# =============================================================================\n\n# If you enable mail confirmation, you need to set the SMTP configuration below\n# Note that smtps will be used for port 465, otherwise plain smtp with optional STARTTLS\nGOTRUE_SMTP_HOST=smtp.gmail.com\nGOTRUE_SMTP_PORT=465\nGOTRUE_SMTP_USER=email_sender@some_company.com\nGOTRUE_SMTP_PASS=email_sender_password\nGOTRUE_SMTP_ADMIN_EMAIL=comp_admin@some_company.com\n\n# Email template URLs for different types of emails\nGOTRUE_MAILER_TEMPLATES_CONFIRMATION=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/confirmation.html\nGOTRUE_MAILER_TEMPLATES_INVITE=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/invite.html\nGOTRUE_MAILER_TEMPLATES_RECOVERY=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/recovery.html\nGOTRUE_MAILER_TEMPLATES_MAGIC_LINK=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/magic_link.html\nGOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/email_change.html\n\n# AppFlowy Cloud Mailer\n# Note that smtps (TLS) is always required, even for ports other than 465\nAPPFLOWY_MAILER_SMTP_HOST=smtp.gmail.com\nAPPFLOWY_MAILER_SMTP_USERNAME=notify@appflowy.io\nAPPFLOWY_MAILER_SMTP_EMAIL=notify@appflowy.io\nAPPFLOWY_MAILER_SMTP_PASSWORD=email_sender_password\nAPPFLOWY_MAILER_SMTP_TLS_KIND=wrapper # \"none\" \"wrapper\" \"required\" \"opportunistic\"\n\n# =============================================================================\n# 🔑 OAUTH PROVIDERS: Optional (configure only the ones you want to use)\n# =============================================================================\n\n# Google OAuth2\nGOTRUE_EXTERNAL_GOOGLE_ENABLED=true\nGOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=\nGOTRUE_EXTERNAL_GOOGLE_SECRET=\nGOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=http://localhost:9999/callback\n\n# GitHub OAuth2\nGOTRUE_EXTERNAL_GITHUB_ENABLED=false\nGOTRUE_EXTERNAL_GITHUB_CLIENT_ID=\nGOTRUE_EXTERNAL_GITHUB_SECRET=\nGOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=http://localhost:9999/callback\n\n# Discord OAuth2\nGOTRUE_EXTERNAL_DISCORD_ENABLED=false\nGOTRUE_EXTERNAL_DISCORD_CLIENT_ID=\nGOTRUE_EXTERNAL_DISCORD_SECRET=\nGOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=http://localhost:9999/callback\n\n# Apple OAuth2\nGOTRUE_EXTERNAL_APPLE_ENABLED=false\nGOTRUE_EXTERNAL_APPLE_CLIENT_ID=\nGOTRUE_EXTERNAL_APPLE_SECRET=\nGOTRUE_EXTERNAL_APPLE_REDIRECT_URI=http://localhost:9999/callback\n\n# =============================================================================\n# 🏗️ INFRASTRUCTURE SERVICES: Object storage and networking\n# =============================================================================\n\n# AWS credentials (used for MinIO in development)\nAWS_ACCESS_KEY=minioadmin\nAWS_SECRET=minioadmin\n\n# =============================================================================\n# 🎛️ ADMIN FRONTEND: Management interface configuration\n# =============================================================================\n\n# URL that connects to redis for admin frontend\nADMIN_FRONTEND_REDIS_URL=redis://localhost:6379\n# URL that connects to gotrue service for admin frontend\nADMIN_FRONTEND_GOTRUE_URL=http://localhost:9999\n# URL that connects to the appflowy cloud service for admin frontend\nADMIN_FRONTEND_APPFLOWY_CLOUD_URL=http://localhost:8000\n# Base URL path for the admin frontend (usually /console for production, can be empty for development)\nADMIN_FRONTEND_PATH_PREFIX=\n\n# =============================================================================\n# 💾 FILE STORAGE: Local MinIO (works out-of-the-box for development)\n# =============================================================================\n\n# File Storage\nAPPFLOWY_S3_CREATE_BUCKET=true\nAPPFLOWY_S3_USE_MINIO=true\nAPPFLOWY_S3_MINIO_URL=http://localhost:9000 # change this if you are using a different address for minio\"\nAPPFLOWY_S3_ACCESS_KEY=${AWS_ACCESS_KEY}\nAPPFLOWY_S3_SECRET_KEY=${AWS_SECRET}\nAPPFLOWY_S3_BUCKET=appflowy\n# APPFLOWY_S3_REGION=us-east-1\n\n# =============================================================================\n# 🤖 AI FEATURES: Optional (configure only if you want AI functionality)\n# =============================================================================\n\n# AppFlowy AI\n# Standard OpenAI API:\n# Set your API key here if you are using the standard OpenAI API.\nAI_OPENAI_API_KEY=\n# If no summary model is provided, there will be no search summary when using AI search.\nAI_OPENAI_API_SUMMARY_MODEL=\"gpt-4o-mini\"\n\n# Azure-hosted OpenAI API:\n# If you're using a self-hosted OpenAI API via Azure, leave AI_OPENAI_API_KEY empty\n# and set the following Azure-specific variables instead. If both are set, the standard OpenAI API will be used.\nAI_AZURE_OPENAI_API_KEY=\nAI_AZURE_OPENAI_API_BASE=\nAI_AZURE_OPENAI_API_VERSION=\n\nAI_SERVER_PORT=5001\nAI_SERVER_HOST=localhost\nAI_DATABASE_URL=postgresql+psycopg://postgres:password@localhost:5432/postgres\nAI_REDIS_URL=redis://localhost:6379\nAI_APPFLOWY_BUCKET_NAME=${APPFLOWY_S3_BUCKET}\nAI_APPFLOWY_HOST=http://localhost:8000\nAI_MINIO_URL=http://localhost:9000\n\n# Embedding Configuration\nAPPFLOWY_EMBEDDING_CHUNK_SIZE=500\nAPPFLOWY_EMBEDDING_CHUNK_OVERLAP=50\n\n# =============================================================================\n# ⚙️ WORKER SERVICES: Background processing (good defaults for development)\n# =============================================================================\n\n# AppFlowy Indexer (for search functionality)\nAPPFLOWY_INDEXER_ENABLED=true\nAPPFLOWY_INDEXER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres\nAPPFLOWY_INDEXER_REDIS_URL=redis://localhost:6379\nAPPFLOWY_INDEXER_EMBEDDING_BUFFER_SIZE=5000\n\n# AppFlowy Worker\nAPPFLOWY_WORKER_REDIS_URL=redis://localhost:6379\nAPPFLOWY_WORKER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres\n\n# =============================================================================\n# 🌐 WEB FRONTEND: AppFlowy Web interface\n# =============================================================================\n\n# AppFlowy Web\nAPPFLOWY_WEB_URL=http://localhost:3000\n\n# =============================================================================\n# 🗄️ PGADMIN: Database Management Web Interface\n# =============================================================================\n\n# PgAdmin credentials for database management web UI\n# You can access pgadmin at http://localhost/pgadmin when running with docker-compose\n# Use the DATABASE_URL values when connecting to the database\nPGADMIN_DEFAULT_EMAIL=admin@example.com\nPGADMIN_DEFAULT_PASSWORD=password\n\n# =============================================================================\n# 🛠️ DEVELOPMENT TOOLS: Database admin, monitoring, etc.\n# =============================================================================\n\n# Log level for the application\nRUST_LOG=info\n\n# Cloudflare tunnel token\nCLOUDFLARE_TUNNEL_TOKEN=\n\n# Enable AI tests in development/CI environment\n# In GitHub CI, this is enabled via the 'ai-test-enabled' feature flag\n# Set to true to run AI-related tests locally (requires valid API keys)\nAI_TEST_ENABLED=false\n"
  },
  {
    "path": "doc/AUTHENTICATION.md",
    "content": "# Authentication\n\nFollow [this](https://appflowy.com/docs/Authentication) guide to set up\n"
  },
  {
    "path": "doc/CONTRIBUTING.md",
    "content": "# Contributing <!-- omit in toc -->\n\nFirst of all, thank you for contributing to AppFlowy Cloud! The goal of this document is to provide everything you need\nto know in order to contribute to AppFlowy Cloud and its different integrations.\n\n- [Assumptions](#assumptions)\n- [How to Contribute](#how-to-contribute)\n- [Development Workflow](#development-workflow)\n\n## Assumptions\n\n1. **You're familiar with [GitHub](https://github.com) and\n   the [Pull Request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests)(\n   PR) workflow.**\n2. **You know about the [AppFlowy community](https://discord.gg/9Q2xaN37tV). Please use this for help.**\n\n## How to Contribute\n\nContributions are welcome! Here's how you can help improve AppFlowy Cloud:\n\n1. Identify or propose enhancements or fixes by\n   checking [existing issues](https://github.com/AppFlowy-IO/AppFlowy-Cloud/issues)\n   or [creating a new one](https://github.com/AppFlowy-IO/AppFlowy-Cloud/issues/new/choose).\n2. [Fork the repository](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) to your own GitHub\n   account. Feel free to discuss your contribution with a maintainer beforehand.\n3. [Create a feature or bugfix branch](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-and-deleting-branches-within-your-repository)\n   in your forked repo.\n4. Familiarize yourself with the [Development Workflow](#development-workflow) for guidelines on maintaining code\n   quality.\n5. Implement your changes on the new branch.\n6. [Open a Pull Request (PR)](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork)\n   against the `main` branch of the original AppFlowy Cloud repo. Await feedback or approval from the maintainers.\n\n## Development Workflow\n\nBefore diving into development, familiarize yourself with the codebase and project standards by reviewing\nthe [Development Guide](./GUIDE.md).\n\n### Setting Up the Local Server\n\nTo start the server on your local machine, run the following script:\n\n```bash\n./script/run_local_server.sh\n```\n\n### Testing\n\nVerify that your changes work as expected by running the test suite:\n\n```bash\ncargo test\n```\n\n### Pull Request (PR) Requirements\n\nFor a pull request to be accepted, it must satisfy the following criteria:\n\n1. **Pass All Tests**: Your PR should not break any existing functionality and must pass all the automated tests.\n\n2. **Linting with Clippy**: Your code must adhere to the linting standards enforced\n   by [`clippy`](https://github.com/rust-lang/rust-clippy). You can check for linting issues using:\n\n   ```bash\n   cargo clippy -- -D warnings\n   ```\n\n   If `clippy` isn't already installed:\n\n   ```bash\n   rustup update\n   rustup component add clippy\n   ```\n\n3. **Code Formatting**: The code must comply with established formatting rules. Use the following commands for\n   formatting and checking your code:\n\n   To format your code:\n\n   ```bash\n   cargo fmt\n   ```\n\n   To validate formatting:\n\n   ```bash\n   cargo fmt --all -- --check\n   ```\n"
  },
  {
    "path": "doc/DEPLOYMENT.md",
    "content": "# Deployment\n\nStep-by-step Self Hosting Guides - From Zero to\nProduction ([link](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production))\n"
  },
  {
    "path": "doc/EC2_SELF_HOST_GUIDE.md",
    "content": "# Installing AppFlowy-Cloud on an AWS EC2 Ubuntu Instance\n\nFollow the guide [here](https://appflowy.com/docs/Installing-AppFlowy-Cloud-on-AWS-EC2)\n"
  },
  {
    "path": "doc/GUIDE.md",
    "content": "# AppFlowy Cloud: Comprehensive Guide\n\n## Overview of File Structure\n\n### Libraries (`libs`)\n- `libs/client-api`: API client for interfacing with AppFlowy-Cloud.\n- `libs/database`: Houses database schema and migration scripts.\n- `libs/database-entity`: Definitions for database entities.\n- `libs/gotrue`: Contains the GoTrue Authentication Server code.\n- `libs/gotrue-entity`: Entity definitions for the GoTrue Auth Server.\n- `libs/realtime`: Realtime server implementation.\n- `libs/collab-rt-entity`: Realtime server entity definitions.\n- `libs/infra`: Scripts and tools for infrastructure management.\n- `libs/app_error`: Custom error types specific to AppFlowy-Cloud.\n\n### Source Code (`src`)\n- `src/api`: Endpoints and handlers for the AppFlowy-Cloud API.\n- `src/biz`: Core business logic of the application.\n- `src/middleware`: Middleware components for API processing.\n\n### Configuration and Migration\n- `configurations`: Contains essential configuration files for various services.\n- `migrations`: Scripts for managing and migrating the Postgres database.\n\n## Service Routing and Access\n\n### Access Points Post Deployment\nAfter executing `docker compose up -d`, AppFlowy-Cloud is accessible at `http://localhost` on ports 80 and 443 with the following routing:\n\n- `/gotrue`: Redirects to the GoTrue Auth Server.\n- `/api`: AppFlowy-Cloud's HTTP API endpoint.\n- `/ws`: WebSocket endpoint for AppFlowy-Cloud.\n- `/console`: User Admin Frontend for AppFlowy.\n- `/pgadmin`: Interface for Postgres database management.\n- `/minio`: User interface for Minio object storage.\n- `/`, `/app`: AppFlowy Web.\n\n![Deployment Architecture](../assets/images/deployment_arch.png)\n\n## Dockerization and Continuous Integration\n\n#### Docker Images\nAppFlowy leverages Docker for efficient deployment and scaling. Docker images are available at:\n- `appflowy_cloud`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/appflowy_cloud/general)\n- `admin_frontend`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/admin_frontend/general)\n- `appflowy_web`: [Docker Hub](https://hub.docker.com/repository/docker/appflowyinc/appflowy_web/general)\n\n#### Automated Builds with GitHub Tags\nThe Docker images are automatically built and updated through a GitHub Actions workflow:\n\n1. **Tag Creation**: A new tag in the GitHub repository indicates a new version or release.\n2. **Automated Build Trigger**: This tag initiates the Docker image building process via GitHub Actions.\n3. **Docker Hub Updates**: The `appflowy_cloud` and `admin_frontend` images are updated on Docker Hub with the latest build.\n"
  },
  {
    "path": "doc/LOCAL_BUILD.md",
    "content": "\n# To build a multi-architecture Docker image\n\nDocker's buildx tool, which is a part of Docker BuildKit. This tool allows you to create images for different platforms from a single build command. Here's a basic rundown of the steps:\n\n1. **Enable experimental features** by setting `\"experimental\": \"enabled\"` in your Docker configuration file (`~/.docker/config.json`).\n\n2. **Install QEMU** on your macOS to emulate different architectures:\n   ```sh\n   brew install qemu\n   ```\n\n3. **Create a new builder** that enables buildx and specify the platforms you want to target:\n   ```sh\n   docker buildx create --name mybuilder --use\n   ```\n\n4. **Inspect the builder** to ensure it's correctly configured and can build for the target platforms:\n   ```sh\n   docker buildx inspect mybuilder --bootstrap\n   ```\n\n5. **Build and push the image** to Docker Hub (or another registry) for the desired platforms using the `--platform` flag:\n   ```sh\n   docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t <username>/myimage:latest --push .\n   ```"
  },
  {
    "path": "doc/OKTA_SAML.md",
    "content": "# Okta Authentication via SAML\nFollow [this](https://appflowy.com/docs/How-to-log-in-using-Okta-SAML-2) guide to set up \n"
  },
  {
    "path": "doc/README.md",
    "content": "# Docs\n- Directory to contain information about usage and development.\n- [Appflowy Cloud Deployment](./DEPLOYMENT.md)\n- [Appflowy with Cloud](https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)\n"
  },
  {
    "path": "docker/gotrue/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nFROM golang as base\nWORKDIR /go/src/supabase\nRUN git clone https://github.com/AppFlowy-IO/auth.git --depth 1 --branch 0.8.0\nWORKDIR /go/src/supabase/auth\nRUN CGO_ENABLED=0 go build -o /auth .\n\nFROM alpine:3.20\nRUN adduser -D -u 1000 supabase\n\nRUN apk add --no-cache ca-certificates curl\nUSER supabase\n\nCOPY --from=base /auth .\nCOPY --from=base /go/src/supabase/auth/migrations ./migrations\n\nCOPY start.sh .\nCMD [\"./start.sh\"]\n"
  },
  {
    "path": "docker/gotrue/start.sh",
    "content": "#!/usr/bin/env sh\n\nset -e\n./auth migrate\nif [ -n \"${GOTRUE_ADMIN_EMAIL}\" ] && [ -n \"${GOTRUE_ADMIN_PASSWORD}\" ]; then\n    set +e\n    echo \"Creating admin user for gotrue...\"\n    command_output=$(./auth admin createuser --admin --confirm \"${GOTRUE_ADMIN_EMAIL}\" \"${GOTRUE_ADMIN_PASSWORD}\" 2>&1)\n    command_status=$?\n    # Check if the command failed\n    if [ $command_status -ne 0 ]; then\n      # Check if the output contains the specific keyword\n      if echo \"$command_output\" | grep -q \"user already exists\"; then\n        echo \"Admin user already exists. Skipping...\"\n      else\n        echo \"Command failed. Exiting.\"\n        echo $command_output\n        exit $command_status\n      fi\n    fi\nfi\nset -e\n./auth\n"
  },
  {
    "path": "docker/pgadmin/servers.json",
    "content": "{\n    \"Servers\": {\n        \"1\": {\n            \"Name\": \"postgres\",\n            \"Group\": \"Servers\",\n            \"Host\": \"postgres\",\n            \"Port\": 5432,\n            \"MaintenanceDB\": \"postgres\",\n            \"Username\": \"postgres\"\n        }\n    }\n}\n"
  },
  {
    "path": "docker/web/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nFROM node:20.12.0 AS builder\n\nWORKDIR /app\n\nARG VERSION=main\n\nRUN npm install -g pnpm@8.5.0\nRUN git clone --depth 1 --branch ${VERSION} https://github.com/AppFlowy-IO/AppFlowy-Web.git .\nRUN pnpm install\nRUN sed -i 's|https://test.appflowy.cloud||g' src/components/main/app.hooks.ts\nRUN pnpm run build\n\nFROM nginx:alpine\nCOPY --from=builder /app/dist /usr/share/nginx/html/\nCOPY nginx.conf /etc/nginx/nginx.conf\n"
  },
  {
    "path": "docker/web/nginx.conf",
    "content": "worker_processes auto;\npid /var/run/nginx.pid;\n\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    include mime.types;\n    default_type application/octet-stream;\n\n    # Basic optimization\n    sendfile on;\n    tcp_nopush on;\n    tcp_nodelay on;\n    keepalive_timeout 65;\n\n    # GZIP compression\n    gzip on;\n    gzip_vary on;\n    gzip_min_length 1k;\n    gzip_comp_level 6;\n    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;\n\n    server {\n        listen 80;\n\n        root /usr/share/nginx/html;\n        index index.html;\n\n        # Static files cache\n        location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n            expires 30d;\n            add_header Cache-Control \"public, no-transform\";\n        }\n\n        # SPA routing\n        location / {\n            try_files $uri $uri/ /index.html;\n            add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n        }\n\n        # Deny access to non public path\n        location ~ /\\. {\n            deny all;\n        }\n    }\n}\n"
  },
  {
    "path": "docker-compose-ci.yml",
    "content": "# Essential services for AppFlowy Cloud\n\nservices:\n  nginx:\n    restart: on-failure\n    image: nginx\n    ports:\n      - ${NGINX_PORT:-80}:80   # Disable this if you are using TLS\n      - ${NGINX_TLS_PORT:-443}:443\n    volumes:\n      - ./nginx/nginx.conf:/etc/nginx/nginx.conf\n      - ./nginx/ssl/certificate.crt:/etc/nginx/ssl/certificate.crt\n      - ./nginx/ssl/private_key.key:/etc/nginx/ssl/private_key.key\n    networks:\n      - shared_network\n      #- ./nginx_logs:/var/log/nginx\n\n  # You do not need this if you have configured to use your own s3 file storage\n  # You can try to access http://localhost/minio/browser/appflowy in your browser\n  minio:\n    restart: on-failure\n    image: minio/minio\n    ports:\n      - 9000:9000\n      - 9001:9001\n    environment:\n      - MINIO_BROWSER_REDIRECT_URL=http://localhost/minio\n      - MINIO_ROOT_USER=${APPFLOWY_S3_ACCESS_KEY:-minioadmin}\n      - MINIO_ROOT_PASSWORD=${APPFLOWY_S3_SECRET_KEY:-minioadmin}\n    command: server /data --console-address \":9001\"\n    volumes:\n      - minio_data:/data\n    networks:\n      - shared_network\n\n  postgres:\n    restart: on-failure\n    image: pgvector/pgvector:pg15\n    healthcheck:\n      test: [ \"CMD\", \"pg_isready\", \"-U\", \"${POSTGRES_USER}\", \"-d\", \"${POSTGRES_DB}\", \"-p\", \"${POSTGRES_PORT:-5432}\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 6\n    environment:\n      - POSTGRES_USER=${POSTGRES_USER:-postgres}\n      - POSTGRES_DB=${POSTGRES_DB:-postgres}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}\n      - POSTGRES_HOST=${POSTGRES_HOST:-postgres}\n      - PGPORT=${POSTGRES_PORT:-5432}\n    command: [\"postgres\", \"-c\", \"port=${POSTGRES_PORT:-5432}\"]\n    networks:\n      - shared_network\n\n  redis:\n    restart: on-failure\n    image: redis\n    ports:\n      - \"6379:6379\"\n    networks:\n      - shared_network\n\n  gotrue:\n    restart: on-failure\n    image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest}\n    depends_on:\n      postgres:\n        condition: service_healthy\n    healthcheck:\n      test: \"curl --fail http://127.0.0.1:9999/health || exit 1\"\n      interval: 5s\n      timeout: 5s\n      retries: 12\n    environment:\n      # There are a lot of options to configure GoTrue. You can reference the example config:\n      # https://github.com/supabase/auth/blob/master/example.env\n      - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL}\n      - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD}\n      - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false}\n      - GOTRUE_SITE_URL=appflowy-flutter://                           # redirected to AppFlowy application\n      - GOTRUE_URI_ALLOW_LIST=**                                      # adjust restrict if necessary\n      - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}                        # authentication secret\n      - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP}\n      # Without this environment variable, the createuser command will create an admin\n      # with the `admin` role as opposed to `supabase_admin`\n      - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin\n      - GOTRUE_DB_DRIVER=postgres\n      - API_EXTERNAL_URL=${API_EXTERNAL_URL}\n      - DATABASE_URL=${GOTRUE_DATABASE_URL}\n      - PORT=9999\n      - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST}                          # e.g. smtp.gmail.com\n      - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT}                          # e.g. 465\n      - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER}                          # email sender, e.g. noreply@appflowy.io\n      - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS}                          # email password\n      - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_INVITE=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_RECOVERY=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/gotrue/verify\n      - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL}                # email with admin privileges e.g. internal@appflowy.io\n      - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns}       # set to 1ns for running tests\n      - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute\n      - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false}     # change this to true to skip email confirmation\n      # Google OAuth config\n      - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED}\n      - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET}\n      - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI}\n      # GITHUB OAuth config\n      - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED}\n      - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET}\n      - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI}\n      # Discord OAuth config\n      - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED}\n      - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID}\n      - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET}\n      - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI}\n    networks:\n      - shared_network\n\n  appflowy_cloud:\n    restart: on-failure\n    ports:\n      - 8000:8000\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_ENVIRONMENT=production\n      - APPFLOWY_DATABASE_URL=${APPFLOWY_DATABASE_URL}\n      - APPFLOWY_REDIS_URI=${APPFLOWY_REDIS_URI}\n      - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}\n      - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL}\n      - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL}\n      - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY}\n      - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}\n      - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}\n      - APPFLOWY_ACCESS_CONTROL=${APPFLOWY_ACCESS_CONTROL}\n      # For the CI testing, we set the database connection to 20. The default value is 40.\n      - APPFLOWY_DATABASE_MAX_CONNECTIONS=20\n      - AI_SERVER_HOST=${AI_SERVER_HOST}\n      - AI_SERVER_PORT=${AI_SERVER_PORT}\n      - APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL}\n      - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}\n      - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}\n      - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}\n      - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL}\n      - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}\n      - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - APPFLOWY_SEARCH_SERVICE_URL=${APPFLOWY_SEARCH_SERVICE_URL:-http://appflowy_search:4002}\n      - APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS=${APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS:-10}\n      - AI_ENABLED=${AI_ENABLED:-true}\n      # SIGNUP_WHITELIST_ENABLED=true requires GOTRUE_DISABLE_SIGNUP=false on\n      # the gotrue service — the whitelist is enforced by GoTrue's\n      # BeforeUserCreated PG hook, which never runs when signup is globally\n      # disabled.\n      - SIGNUP_WHITELIST_ENABLED=${SIGNUP_WHITELIST_ENABLED:-false}\n      - GUEST_INVITES_REQUIRE_ADMIN_APPROVAL=${GUEST_INVITES_REQUIRE_ADMIN_APPROVAL:-false}\n    build:\n      context: .\n      dockerfile: Dockerfile\n      args:\n        FEATURES: \"\"\n        PROFILE: ci\n    networks:\n      - shared_network\n    image: appflowyinc/appflowy_cloud:${APPFLOWY_CLOUD_VERSION:-latest}\n    depends_on:\n      gotrue:\n        condition: service_healthy\n      appflowy_search:\n        condition: service_started\n\n  admin_frontend:\n    restart: on-failure\n    build:\n      context: .\n      dockerfile: ./admin_frontend/Dockerfile\n    image: appflowyinc/admin_frontend:${APPFLOWY_ADMIN_FRONTEND_VERSION:-latest}\n    ports:\n      - 3000:3000\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - ADMIN_FRONTEND_REDIS_URL=${ADMIN_FRONTEND_REDIS_URL:-redis://redis:6379}\n      - ADMIN_FRONTEND_GOTRUE_URL=${ADMIN_FRONTEND_GOTRUE_URL:-http://gotrue:9999}\n      - ADMIN_FRONTEND_APPFLOWY_CLOUD_URL=${ADMIN_FRONTEND_APPFLOWY_CLOUD_URL:-http://appflowy_cloud:8000}\n      - ADMIN_FRONTEND_PATH_PREFIX=${ADMIN_FRONTEND_PATH_PREFIX:-}\n    networks:\n      - shared_network\n    depends_on:\n      gotrue:\n        condition: service_healthy\n      appflowy_cloud:\n        condition: service_started\n\n  ai:\n    restart: on-failure\n    image: appflowyinc/appflowy_ai_premium:${APPFLOWY_AI_VERSION:-latest}\n    ports:\n      - \"5001:5001\"\n    environment:\n      - OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - AI_AWS_ACCESS_KEY_ID=${APPFLOWY_S3_ACCESS_KEY}\n      - AI_AWS_SECRET_ACCESS_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - AI_APPFLOWY_BUCKET_NAME=${AI_APPFLOWY_BUCKET_NAME}\n      - AI_SERVER_PORT=${AI_SERVER_PORT}\n      - AI_DATABASE_URL=${AI_DATABASE_URL}\n      - AI_REDIS_URL=${AI_REDIS_URL}\n      - AI_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - AI_MINIO_URL=${AI_MINIO_URL}\n      - AI_APPFLOWY_HOST=${AI_APPFLOWY_HOST}\n      - SUPPORT_OPENAI_V3_IMAGE_MODEL=false\n      - LOG_LEVEL=DEBUG\n    networks:\n      - shared_network\n\n  appflowy_worker:\n    restart: on-failure\n    image: appflowyinc/appflowy_worker:${APPFLOWY_WORKER_VERSION:-latest}\n    build:\n      context: .\n      dockerfile: ./services/appflowy-worker/Dockerfile\n    ports:\n      - \"4001:4001\"\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_WORKER_REDIS_URL=${APPFLOWY_WORKER_REDIS_URL:-redis://redis:6379}\n      - APPFLOWY_WORKER_ENVIRONMENT=production\n      - APPFLOWY_WORKER_DATABASE_URL=${APPFLOWY_WORKER_DATABASE_URL}\n      - APPFLOWY_WORKER_DATABASE_NAME=${APPFLOWY_WORKER_DATABASE_NAME}\n      - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL}\n      - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY}\n      - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}\n      - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}\n      - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}\n      - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}\n      - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}\n      - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL}\n      - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}\n    networks:\n      - shared_network\n\n  appflowy_search:\n    restart: on-failure\n    image: appflowyinc/appflowy_search:${APPFLOWY_SEARCH_VERSION:-latest}\n    ports:\n      - \"4002:4002\"\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_SEARCH_HOST=${APPFLOWY_SEARCH_HOST:-[::]}\n      - APPFLOWY_SEARCH_PORT=${APPFLOWY_SEARCH_PORT:-4002}\n      - APPFLOWY_SEARCH_DATABASE_URL=${APPFLOWY_DATABASE_URL}\n      - APPFLOWY_SEARCH_REDIS_URL=${APPFLOWY_REDIS_URI}\n      - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}\n      - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}\n      - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION}\n      - APPFLOWY_BACKGROUND_INDEXER_ENABLED=true\n      - APPFLOWY_INDEXER_DATABASE_ENABLED=${APPFLOWY_INDEXER_DATABASE_ENABLED:-false}\n      - APPFLOWY_KEYWORD_SEARCH_ENABLED=${APPFLOWY_KEYWORD_SEARCH_ENABLED:-false}\n      - APPFLOWY_KEYWORD_WORKER_ENABLED=true\n      - APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES=${APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES:-0}\n      - APPFLOWY_KEYWORD_INDEX_DIR=${APPFLOWY_KEYWORD_INDEX_DIR:-/var/lib/appflowy/keyword_index}\n      - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}\n    volumes:\n      - keyword_index_data:/var/lib/appflowy/keyword_index\n    networks:\n      - shared_network\n    depends_on:\n      postgres:\n        condition: service_healthy\n\nvolumes:\n  postgres_data:\n  minio_data:\n  keyword_index_data:\n\nnetworks:\n  shared_network:\n    name: appflowy_network\n    driver: bridge\n"
  },
  {
    "path": "docker-compose-dev.yml",
    "content": "services:\n  minio:\n    restart: on-failure\n    image: minio/minio\n    ports:\n      - 9000:9000\n      - 9001:9001\n    environment:\n      - MINIO_BROWSER_REDIRECT_URL=http://localhost:9001\n    command: server /data --console-address \":9001\"\n\n  postgres:\n    restart: on-failure\n    image: pgvector/pgvector:pg15\n    environment:\n      - POSTGRES_USER=${POSTGRES_USER:-postgres}\n      - POSTGRES_DB=${POSTGRES_DB:-postgres}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}\n      - POSTGRES_HOST=${POSTGRES_HOST:-postgres}\n      - SUPABASE_PASSWORD=${SUPABASE_PASSWORD:-root}\n      - PGPORT=${POSTGRES_PORT:-5432}\n    command: [\"postgres\", \"-c\", \"port=${POSTGRES_PORT:-5432}\"]\n    ports:\n      - \"${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}\"\n    healthcheck:\n      test: [ \"CMD\", \"pg_isready\", \"-U\", \"${POSTGRES_USER}\", \"-d\", \"${POSTGRES_DB}\", \"-p\", \"${POSTGRES_PORT:-5432}\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 12\n\n  redis:\n    restart: on-failure\n    image: redis\n    ports:\n      - 6379:6379\n\n  gotrue:\n    restart: on-failure\n    image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest}\n    depends_on:\n      postgres:\n        condition: service_healthy\n    environment:\n      # Gotrue config: https://github.com/supabase/auth/blob/master/example.env\n      - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL}\n      - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD}\n      - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false}\n      - GOTRUE_SITE_URL=appflowy-flutter://                           # redirected to AppFlowy application\n      - GOTRUE_URI_ALLOW_LIST=**                                      # adjust restrict if necessary\n      - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}                        # authentication secret\n      - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP}\n      # Without this environment variable, the createuser command will create an admin\n      # with the `admin` role as opposed to `supabase_admin`\n      - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin\n      - GOTRUE_DB_DRIVER=postgres\n      - API_EXTERNAL_URL=${API_EXTERNAL_URL}\n      - DATABASE_URL=${GOTRUE_DATABASE_URL}\n      - PORT=9999\n      - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/verify\n      - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST}                              # e.g. smtp.gmail.com\n      - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT}                              # e.g. 465\n      - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER}                              # email sender, e.g. noreply@appflowy.io\n      - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS}                              # email password\n      - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL}                # email with admin privileges e.g. internal@appflowy.io\n      - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns}       # set to 1ns for running tests\n      - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute\n      - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false}     # change this to true to skip email confirmation\n      # Google OAuth config\n      - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED}\n      - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET}\n      - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI}\n      # Apple OAuth config\n      - GOTRUE_EXTERNAL_APPLE_ENABLED=${GOTRUE_EXTERNAL_APPLE_ENABLED}\n      - GOTRUE_EXTERNAL_APPLE_CLIENT_ID=${GOTRUE_EXTERNAL_APPLE_CLIENT_ID}\n      - GOTRUE_EXTERNAL_APPLE_SECRET=${GOTRUE_EXTERNAL_APPLE_SECRET}\n      - GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=${GOTRUE_EXTERNAL_APPLE_REDIRECT_URI}\n      # GITHUB OAuth config\n      - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED}\n      - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET}\n      - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI}\n      # Discord OAuth config\n      - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED}\n      - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID}\n      - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET}\n      - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI}\n      - GOTRUE_MAILER_TEMPLATES_CONFIRMATION=${GOTRUE_MAILER_TEMPLATES_CONFIRMATION}\n    ports:\n      - 9999:9999\n\n  pgadmin:\n    restart: on-failure\n    image: dpage/pgadmin4\n    depends_on:\n      - postgres\n    environment:\n      - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}\n      - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}\n    ports:\n      - 5400:80\n    volumes:\n      - ./docker/pgadmin/servers.json:/pgadmin4/servers.json\n\nvolumes:\n  postgres_data:\n"
  },
  {
    "path": "docker-compose-extras.yml",
    "content": "# Non-essential additional services\ninclude:\n  - docker-compose.yml\n\nservices:\n  tunnel:\n    image: cloudflare/cloudflared\n    restart: unless-stopped\n    command: tunnel --no-autoupdate run\n    environment:\n      - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}\n\n  pgadmin:\n    restart: on-failure\n    image: dpage/pgadmin4\n    environment:\n      - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}\n      - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}\n    volumes:\n      - ./docker/pgadmin/servers.json:/pgadmin4/servers.json\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "# Essential services for AppFlowy Cloud\n\nservices:\n  nginx:\n    restart: on-failure\n    image: nginx\n    ports:\n      - ${NGINX_PORT:-80}:80   # Disable this if you are using TLS\n      - ${NGINX_TLS_PORT:-443}:443\n    volumes:\n      - ./nginx/nginx.conf:/etc/nginx/nginx.conf\n      - ./nginx/ssl/certificate.crt:/etc/nginx/ssl/certificate.crt\n      - ./nginx/ssl/private_key.key:/etc/nginx/ssl/private_key.key\n\n  # You do not need this if you have configured to use your own s3 file storage\n  minio:\n    restart: on-failure\n    image: minio/minio\n    environment:\n      - MINIO_BROWSER_REDIRECT_URL=${APPFLOWY_BASE_URL?:err}/minio\n      - MINIO_ROOT_USER=${APPFLOWY_S3_ACCESS_KEY:-minioadmin}\n      - MINIO_ROOT_PASSWORD=${APPFLOWY_S3_SECRET_KEY:-minioadmin}\n    command: server /data --console-address \":9001\"\n    healthcheck:\n      test: [ \"CMD\", \"curl\", \"-f\", \"http://localhost:9000/minio/health/live\" ]\n      interval: 30s\n      timeout: 20s\n      retries: 3\n    volumes:\n      - minio_data:/data\n\n  postgres:\n    restart: on-failure\n    image: pgvector/pgvector:pg16\n    environment:\n      - POSTGRES_USER=${POSTGRES_USER:-postgres}\n      - POSTGRES_DB=${POSTGRES_DB:-postgres}\n      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}\n      - POSTGRES_HOST=${POSTGRES_HOST:-postgres}\n      - PGPORT=${POSTGRES_PORT:-5432}\n    command: [ \"postgres\", \"-c\", \"port=${POSTGRES_PORT:-5432}\" ]\n    healthcheck:\n      test: [ \"CMD\", \"pg_isready\", \"-U\", \"${POSTGRES_USER}\", \"-d\", \"${POSTGRES_DB}\", \"-p\", \"${POSTGRES_PORT:-5432}\" ]\n      interval: 5s\n      timeout: 5s\n      retries: 12\n    volumes:\n      - postgres_data:/var/lib/postgresql/data\n\n  redis:\n    restart: on-failure\n    image: redis\n\n  gotrue:\n    restart: on-failure\n    depends_on:\n      postgres:\n        condition: service_healthy\n    healthcheck:\n      test: \"curl --fail http://127.0.0.1:9999/health || exit 1\"\n      interval: 5s\n      timeout: 5s\n      retries: 12\n      start_period: 40s\n    image: appflowyinc/gotrue:${GOTRUE_VERSION:-latest}\n    environment:\n      # There are a lot of options to configure GoTrue. You can reference the example config:\n      # https://github.com/supabase/auth/blob/master/example.env\n      # The initial GoTrue Admin user to create, if not already exists.\n      - GOTRUE_ADMIN_EMAIL=${GOTRUE_ADMIN_EMAIL}\n      # The initial GoTrue Admin user password to create, if not already exists.\n      # If the user already exists, the update will be skipped.\n      - GOTRUE_ADMIN_PASSWORD=${GOTRUE_ADMIN_PASSWORD}\n      - GOTRUE_DISABLE_SIGNUP=${GOTRUE_DISABLE_SIGNUP:-false}\n      - GOTRUE_SITE_URL=appflowy-flutter://                           # redirected to AppFlowy application\n      - GOTRUE_URI_ALLOW_LIST=** # adjust restrict if necessary\n      - GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}                        # authentication secret\n      - GOTRUE_JWT_EXP=${GOTRUE_JWT_EXP}\n      # Without this environment variable, the createuser command will create an admin\n      # with the `admin` role as opposed to `supabase_admin`\n      - GOTRUE_JWT_ADMIN_GROUP_NAME=supabase_admin\n      - GOTRUE_DB_DRIVER=postgres\n      - API_EXTERNAL_URL=${API_EXTERNAL_URL}\n      - DATABASE_URL=${GOTRUE_DATABASE_URL}\n      - PORT=9999\n      - GOTRUE_SMTP_HOST=${GOTRUE_SMTP_HOST}                          # e.g. smtp.gmail.com\n      - GOTRUE_SMTP_PORT=${GOTRUE_SMTP_PORT}                          # e.g. 465\n      - GOTRUE_SMTP_USER=${GOTRUE_SMTP_USER}                          # email sender, e.g. noreply@appflowy.io\n      - GOTRUE_SMTP_PASS=${GOTRUE_SMTP_PASS}                          # email password\n      - GOTRUE_MAILER_URLPATHS_CONFIRMATION=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_INVITE=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_RECOVERY=/gotrue/verify\n      - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/gotrue/verify\n      - GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=${GOTRUE_MAILER_TEMPLATES_MAGIC_LINK}\n      - GOTRUE_SMTP_ADMIN_EMAIL=${GOTRUE_SMTP_ADMIN_EMAIL}                # email with admin privileges e.g. internal@appflowy.io\n      - GOTRUE_SMTP_MAX_FREQUENCY=${GOTRUE_SMTP_MAX_FREQUENCY:-1ns}       # set to 1ns for running tests\n      - GOTRUE_RATE_LIMIT_EMAIL_SENT=${GOTRUE_RATE_LIMIT_EMAIL_SENT:-100} # number of email sendable per minute\n      - GOTRUE_MAILER_AUTOCONFIRM=${GOTRUE_MAILER_AUTOCONFIRM:-false}     # change this to true to skip email confirmation\n      # Google OAuth config\n      - GOTRUE_EXTERNAL_GOOGLE_ENABLED=${GOTRUE_EXTERNAL_GOOGLE_ENABLED}\n      - GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GOOGLE_SECRET=${GOTRUE_EXTERNAL_GOOGLE_SECRET}\n      - GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI}\n      # GITHUB OAuth config\n      - GOTRUE_EXTERNAL_GITHUB_ENABLED=${GOTRUE_EXTERNAL_GITHUB_ENABLED}\n      - GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=${GOTRUE_EXTERNAL_GITHUB_CLIENT_ID}\n      - GOTRUE_EXTERNAL_GITHUB_SECRET=${GOTRUE_EXTERNAL_GITHUB_SECRET}\n      - GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=${GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI}\n      # Discord OAuth config\n      - GOTRUE_EXTERNAL_DISCORD_ENABLED=${GOTRUE_EXTERNAL_DISCORD_ENABLED}\n      - GOTRUE_EXTERNAL_DISCORD_CLIENT_ID=${GOTRUE_EXTERNAL_DISCORD_CLIENT_ID}\n      - GOTRUE_EXTERNAL_DISCORD_SECRET=${GOTRUE_EXTERNAL_DISCORD_SECRET}\n      - GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI=${GOTRUE_EXTERNAL_DISCORD_REDIRECT_URI}\n      # SAML 2.0 OAuth config\n      - GOTRUE_SAML_ENABLED=${GOTRUE_SAML_ENABLED}\n      - GOTRUE_SAML_PRIVATE_KEY=${GOTRUE_SAML_PRIVATE_KEY}\n\n  appflowy_cloud:\n    restart: on-failure\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_ENVIRONMENT=production\n      - APPFLOWY_DATABASE_URL=${APPFLOWY_DATABASE_URL}\n      - APPFLOWY_REDIS_URI=${APPFLOWY_REDIS_URI}\n      - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}\n      - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL}\n      - APPFLOWY_S3_CREATE_BUCKET=${APPFLOWY_S3_CREATE_BUCKET}\n      - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL}\n      - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY}\n      - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}\n      - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}\n      - APPFLOWY_S3_PRESIGNED_URL_ENDPOINT=${APPFLOWY_S3_PRESIGNED_URL_ENDPOINT}\n      - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}\n      - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}\n      - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}\n      - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL}\n      - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}\n      - APPFLOWY_MAILER_SMTP_TLS_KIND=${APPFLOWY_MAILER_SMTP_TLS_KIND}\n      - APPFLOWY_ACCESS_CONTROL=${APPFLOWY_ACCESS_CONTROL}\n      - APPFLOWY_DATABASE_MAX_CONNECTIONS=${APPFLOWY_DATABASE_MAX_CONNECTIONS}\n      - AI_SERVER_HOST=${AI_SERVER_HOST}\n      - AI_SERVER_PORT=${AI_SERVER_PORT}\n      - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL}\n      - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL}\n      - ASSEMBLYAI_API_KEY=${ASSEMBLYAI_API_KEY}\n      - ASSEMBLYAI_API_BASE=${ASSEMBLYAI_API_BASE}\n      - ASSEMBLYAI_STREAMING_API_BASE=${ASSEMBLYAI_STREAMING_API_BASE}\n      - APPFLOWY_SEARCH_SERVICE_URL=${APPFLOWY_SEARCH_SERVICE_URL:-http://appflowy_search:4002}\n      - APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS=${APPFLOWY_SEARCH_REQUEST_TIMEOUT_SECS:-10}\n      - AI_ENABLED=${AI_ENABLED:-true}\n      # SIGNUP_WHITELIST_ENABLED=true requires GOTRUE_DISABLE_SIGNUP=false on\n      # the gotrue service. The whitelist is enforced by GoTrue's\n      # BeforeUserCreated PG hook, which never runs when signup is globally\n      # disabled — leaving the whitelist a no-op. Set GOTRUE_DISABLE_SIGNUP=false\n      # and rely on the whitelist + invitations to gate who can sign up.\n      - SIGNUP_WHITELIST_ENABLED=${SIGNUP_WHITELIST_ENABLED:-false}\n      - GUEST_INVITES_REQUIRE_ADMIN_APPROVAL=${GUEST_INVITES_REQUIRE_ADMIN_APPROVAL:-false}\n    image: appflowyinc/appflowy_cloud:${APPFLOWY_CLOUD_VERSION:-latest}\n    healthcheck:\n      test: \"curl --fail http://127.0.0.1:8000/api/health || exit 1\"\n      interval: 5s\n      timeout: 5s\n      retries: 12\n    depends_on:\n      gotrue:\n        condition: service_healthy\n\n  admin_frontend:\n    restart: on-failure\n    image: appflowyinc/admin_frontend:${APPFLOWY_ADMIN_FRONTEND_VERSION:-latest}\n    environment:\n      - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_GOTRUE_BASE_URL:-http://gotrue:9999}\n      - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL:-http://appflowy_cloud:8000}\n\n    depends_on:\n      gotrue:\n        condition: service_healthy\n      appflowy_cloud:\n        condition: service_healthy\n\n  ai:\n    restart: on-failure\n    image: appflowyinc/appflowy_ai:${APPFLOWY_AI_VERSION:-latest}\n    environment:\n      - AI_SERVER_PORT=${AI_SERVER_PORT}\n      - OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - DEFAULT_AI_MODEL=gpt-4.1-mini # Make sure the model is available in your OpenAI account\n      - DEFAULT_AI_COMPLETION_MODEL=gpt-4.1-mini # Make sure the model is available in your OpenAI account\n      - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}\n      - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}\n      - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION}\n      - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY}\n      - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}\n      - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}\n      - AI_DATABASE_URL=${APPFLOWY_DATABASE_URL}\n      - AI_REDIS_URL=${APPFLOWY_REDIS_URI}\n      - AI_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - AI_MINIO_URL=${APPFLOWY_S3_MINIO_URL}\n      - AI_APPFLOWY_HOST=${APPFLOWY_BASE_URL}\n      - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}\n    healthcheck:\n      test: [ \"CMD\", \"curl\", \"-f\", \"http://localhost:5001/health\" ]\n      interval: 30s\n      timeout: 10s\n      retries: 3\n      start_period: 40s\n    depends_on:\n      postgres:\n        condition: service_healthy\n      appflowy_cloud:\n        condition: service_healthy\n\n  appflowy_worker:\n    restart: on-failure\n    image: appflowyinc/appflowy_worker:${APPFLOWY_WORKER_VERSION:-latest}\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_ENVIRONMENT=production\n      - APPFLOWY_WORKER_REDIS_URL=${APPFLOWY_WORKER_REDIS_URL:-redis://redis:6379}\n      - APPFLOWY_WORKER_ENVIRONMENT=production\n      - APPFLOWY_WORKER_DATABASE_URL=${APPFLOWY_WORKER_DATABASE_URL}\n      - APPFLOWY_WORKER_DATABASE_NAME=${APPFLOWY_WORKER_DATABASE_NAME}\n      - APPFLOWY_WORKER_IMPORT_TICK_INTERVAL=30\n      - APPFLOWY_S3_USE_MINIO=${APPFLOWY_S3_USE_MINIO}\n      - APPFLOWY_S3_MINIO_URL=${APPFLOWY_S3_MINIO_URL}\n      - APPFLOWY_S3_ACCESS_KEY=${APPFLOWY_S3_ACCESS_KEY}\n      - APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}\n      - APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}\n      - APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}\n      - APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}\n      - APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}\n      - APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}\n      - APPFLOWY_MAILER_SMTP_EMAIL=${APPFLOWY_MAILER_SMTP_EMAIL}\n      - APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}\n      - APPFLOWY_MAILER_SMTP_TLS_KIND=${APPFLOWY_MAILER_SMTP_TLS_KIND}\n    depends_on:\n      postgres:\n        condition: service_healthy\n      appflowy_cloud:\n        condition: service_healthy\n\n  appflowy_search:\n    restart: on-failure\n    image: appflowyinc/appflowy_search:${APPFLOWY_SEARCH_VERSION:-latest}\n    environment:\n      - RUST_LOG=${RUST_LOG:-info}\n      - APPFLOWY_SEARCH_HOST=${APPFLOWY_SEARCH_HOST:-[::]}\n      - APPFLOWY_SEARCH_PORT=${APPFLOWY_SEARCH_PORT:-4002}\n      - APPFLOWY_SEARCH_DATABASE_URL=${APPFLOWY_DATABASE_URL}\n      - APPFLOWY_SEARCH_REDIS_URL=${APPFLOWY_REDIS_URI}\n      - AI_OPENAI_API_KEY=${AI_OPENAI_API_KEY}\n      - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY}\n      - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}\n      - AZURE_OPENAI_API_VERSION=${AZURE_OPENAI_API_VERSION}\n      - APPFLOWY_BACKGROUND_INDEXER_ENABLED=true\n      - APPFLOWY_INDEXER_DATABASE_ENABLED=${APPFLOWY_INDEXER_DATABASE_ENABLED:-false}\n      - APPFLOWY_KEYWORD_SEARCH_ENABLED=${APPFLOWY_KEYWORD_SEARCH_ENABLED:-true}\n      - APPFLOWY_KEYWORD_WORKER_ENABLED=true\n      - APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES=${APPFLOWY_KEYWORD_INDEX_MAP_SIZE_BYTES:-2147483648}\n      - APPFLOWY_KEYWORD_INDEX_DIR=${APPFLOWY_KEYWORD_INDEX_DIR:-/var/lib/appflowy/keyword_index}\n      - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET}\n    volumes:\n      - keyword_index_data:/var/lib/appflowy/keyword_index\n    depends_on:\n      postgres:\n        condition: service_healthy\n\n  appflowy_web:\n    restart: on-failure\n    image: appflowyinc/appflowy_web:${APPFLOWY_WEB_VERSION:-latest}\n    depends_on:\n      appflowy_cloud:\n        condition: service_healthy\n    environment:\n      - APPFLOWY_BASE_URL=${APPFLOWY_BASE_URL}\n      - APPFLOWY_GOTRUE_BASE_URL=${APPFLOWY_BASE_URL}/gotrue\n      - APPFLOWY_WS_BASE_URL=${APPFLOWY_WEBSOCKET_BASE_URL}\nvolumes:\n  postgres_data:\n  minio_data:\n  keyword_index_data:\n"
  },
  {
    "path": "email_template/.editorconfig",
    "content": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\n"
  },
  {
    "path": "email_template/.gitignore",
    "content": "node_modules\nbuild_local\n.vscode\n.idea\nThumbs.db\n.DS_Store\nnpm-debug.log\nyarn-error.log\n"
  },
  {
    "path": "email_template/.npmrc",
    "content": "shamefully-hoist=true\n"
  },
  {
    "path": "email_template/LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) Cosmin Popovici\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "email_template/README.md",
    "content": "## About\nThis contains the source code for the mail templates used by GoTrue and\nAppFlowy Cloud services.\n\n## Development\n\nRun this command and follow the prompts\n\n```bash\n# install pnpm@8.5.0\n\nnpm install -g pnpm@8.5.0\n\npnpm i\n\npnpm run dev\n```\n\n## Build\n\nRun this command to build the project to generate the final output in the assets/mailer_templates/build_production\nfolder\n\n```bash\n   pnpm run build\n```\n"
  },
  {
    "path": "email_template/config.js",
    "content": "/** @type {import('@maizzle/framework').Config} */\n\n/*\n|-------------------------------------------------------------------------------\n| Development config                      https://maizzle.com/docs/environments\n|-------------------------------------------------------------------------------\n|\n| The exported object contains the default Maizzle settings for development.\n| This is used when you run `maizzle build` or `maizzle serve` and it has\n| the fastest build time, since most transformations are disabled.\n|\n*/\n\nmodule.exports = {\n  build: {\n    templates: {\n      source: \"src/templates\",\n      destination: {\n        path: \"build_local\",\n      },\n      assets: {\n        source: \"src/images\",\n        destination: \"images\",\n      },\n    },\n  },\n  locals: {\n    cdnBaseUrl: \"\",\n    userIconUrl: \"https://cdn-icons-png.flaticon.com/512/1077/1077012.png\",\n    error: \"Test error message\",\n    detailError: \"Test detail error message\",\n    userName: \"John Doe\",\n    acceptUrl: \"https://appflowy.io\",\n    approveUrl: \"https://appflowy.io\",\n    launchWorkspaceUrl: \"https://appflowy.io\",\n    workspaceName: \"AppFlowy\",\n    workspaceMembersCount: \"100\",\n    workspaceIconURL: \"https://cdn-icons-png.flaticon.com/512/1078/1078013.png\",\n    mentionedPageName: \"Test Page\",\n    mentionedPageUrl: \"https://appflowy.io\",\n    mentionerName: \"John Doe\",\n    mentionerIconUrl: \"https://cdn-icons-png.flaticon.com/512/1077/1077012.png\",\n    mentionedAt: \"Jul 22, 2025, 3:42 PM (UTC)\",\n  },\n};\n"
  },
  {
    "path": "email_template/config.production.js",
    "content": "/** @type {import('@maizzle/framework').Config} */\n\n/*\n|-------------------------------------------------------------------------------\n| Production config                       https://maizzle.com/docs/environments\n|-------------------------------------------------------------------------------\n|\n| This is where you define settings that optimize your emails for production.\n| These will be merged on top of the base config.js, so you only need to\n| specify the options that are changing.\n|\n*/\n\nmodule.exports = {\n  build: {\n    templates: {\n      destination: {\n        path: \"../assets/mailer_templates/build_production\",\n      },\n    },\n  },\n  locals: {\n    cdnBaseUrl:\n      \"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/\",\n    error: \"{{ error }}\",\n    detailError: \"{{ error_detail }}\",\n    userIconUrl: \"{{ user_icon_url }}\",\n    importFileName: \"{{ import_file_name }}\",\n    importTaskId: \"{{ import_task_id }}\",\n    userName: \"{{ username }}\",\n    acceptUrl: \"{{ accept_url }}\",\n    approveUrl: \"{{ approve_url }}\",\n    launchWorkspaceUrl: \"{{ launch_workspace_url }}\",\n    workspaceName: \"{{ workspace_name }}\",\n    workspaceMembersCount: \"{{ workspace_member_count }}\",\n    workspaceIconURL: \"{{ workspace_icon_url }}\",\n    mentionedPageName: \"{{ mentioned_page_name }}\",\n    mentionedPageUrl: \"{{ mentioned_page_url }}\",\n    mentionerName: \"{{ mentioner_name }}\",\n    mentionerIconUrl: \"{{ mentioner_icon_url }}\",\n    mentionedAt: \"{{ mentioned_at }}\",\n  },\n  inlineCSS: true,\n  removeUnusedCSS: true,\n  shorthandCSS: true,\n  prettify: true,\n};\n"
  },
  {
    "path": "email_template/package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"maizzle serve\",\n    \"build\": \"maizzle build production\"\n  },\n  \"dependencies\": {\n    \"@maizzle/framework\": \"4.4.6\",\n    \"tailwindcss-box-shadow\": \"^2.0.0\",\n    \"tailwindcss-email-variants\": \"^2.0.0\",\n    \"tailwindcss-mso\": \"^1.4.1\",\n    \"tailwindcss\": \"^3.3.0\"\n  },\n  \"engines\": {\n    \"pnpm\": \">=8.0.0 <9.0.0\",\n    \"node\": \">=14.0.0\"\n  }\n}\n"
  },
  {
    "path": "email_template/src/components/button.html",
    "content": "<script props>\n  module.exports = {\n    align: {\n      left: 'text-left',\n      center: 'text-center',\n      right: 'text-right',\n    }[props.align],\n    href: props.href,\n    msoPt: props['mso-pt'] || '16px',\n    msoPb: props['mso-pb'] || '30px',\n  }\n</script>\n\n<div class=\"{{ align }}\">\n  <a\n    attributes\n    href=\"{{ href }}\"\n    class=\"inline-block py-4 px-6 text-base leading-none font-semibold rounded text-slate-50 bg-indigo-700 [text-decoration:none]\"\n  >\n    <outlook>\n      <i class=\"mso-font-width-[150%]\" style=\"mso-text-raise: {{ msoPb }};\" hidden>&emsp;</i>\n    </outlook>\n    <span style=\"mso-text-raise: {{ msoPt }}\"><content /></span>\n    <outlook>\n      <i class=\"mso-font-width-[150%]\" hidden>&emsp;&#8203;</i>\n    </outlook>\n  </a>\n</div>\n"
  },
  {
    "path": "email_template/src/components/divider.html",
    "content": "<script props>\n  // https://maizzle.com/docs/components/divider\n  module.exports = {\n    height: props.height || \"1px\",\n    color: props.color, // any CSS color value\n    top: props.top, // top margin\n    bottom: props.bottom, // bottom margin\n    left: props.left, // left margin\n    right: props.right, // right margin\n    spaceY: props[\"space-y\"] || \"24px\", // top and bottom margin\n    spaceX: props[\"space-x\"], // right and left margin\n    hasBgClass:\n      props.class && props.class.split(\" \").some((c) => c.startsWith(\"bg-\")),\n  };\n</script>\n\n<div\n  role=\"separator\"\n  class=\"{{ (!color && !hasBgClass) && 'bg-slate-300' }}\"\n  style=\"height: {{ height }};\n    line-height: {{ height }};\n    {{ color && `background-color: ${color}` }};\n    margin: 0;\n    {{ spaceY && `margin-top: ${spaceY}; margin-bottom: ${spaceY}` }};\n    {{ spaceX && `margin-left: ${spaceX}; margin-right: ${spaceX}` }};\n    {{ top && `margin-top: ${top}` }};\n    {{ bottom && `margin-bottom: ${bottom}` }};\n    {{ left && `margin-left: ${left}` }};\n    {{ right && `margin-right: ${right}` }};\n  \"\n></div>\n"
  },
  {
    "path": "email_template/src/components/footer.html",
    "content": "<!-- Footer container - implements flex-direction: column effect -->\n<table\n  width=\"100%\"\n  border=\"0\"\n  cellpadding=\"0\"\n  cellspacing=\"0\"\n  style=\"max-width: 400px; margin: 0 auto\"\n>\n  <tr>\n    <td align=\"center\" style=\"padding: 0\">\n      <!-- Footer content items -->\n      <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\">\n        <!-- Footer first row - divider line -->\n        <tr>\n          <td align=\"center\" style=\"padding-bottom: 24px\">\n            <div\n              role=\"separator\"\n              style=\"\n                height: 1px;\n                line-height: 1px;\n                background-color: #e4e8f5;\n                margin: 0;\n                width: 100%;\n              \"\n            ></div>\n          </td>\n        </tr>\n\n        <!-- Footer second item - brand tagline -->\n        <tr>\n          <td align=\"center\" style=\"padding-bottom: 8px\">\n            <div\n              style=\"\n                color: #6f748c;\n                text-align: center;\n                font-family:\n                  &quot;SF Pro Text&quot;,\n                  -apple-system,\n                  BlinkMacSystemFont,\n                  &quot;Segoe UI&quot;,\n                  Roboto,\n                  Helvetica,\n                  Arial,\n                  sans-serif;\n                font-size: 12px;\n                font-style: normal;\n                font-weight: 400;\n                line-height: 18px;\n                letter-spacing: 0.1px;\n                margin: 0;\n                padding: 0;\n              \"\n            >\n              Bring projects, knowledge, and teams together with the power of\n              AI.\n            </div>\n          </td>\n        </tr>\n\n        <!-- Footer third item - social media links -->\n        <tr>\n          <td align=\"center\" style=\"padding-bottom: 8px\">\n            <!-- Social media links container - implements flex gap: 4px effect -->\n            <table\n              border=\"0\"\n              cellpadding=\"0\"\n              cellspacing=\"0\"\n              style=\"margin: 0 auto\"\n            >\n              <tr>\n                <td style=\"padding-right: 12px\">\n                  <a\n                    href=\"https://twitter.com/appflowy\"\n                    style=\"\n                      text-decoration: none;\n                      display: inline-block;\n                      width: 28px;\n                      height: 28px;\n                      text-align: center;\n                      line-height: 28px;\n                    \"\n                  >\n                    <img\n                      src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                      width=\"20\"\n                      height=\"20\"\n                      alt=\"Twitter\"\n                      style=\"border: 0; vertical-align: middle\"\n                    />\n                  </a>\n                </td>\n                <td style=\"padding-right: 12px\">\n                  <a\n                    href=\"https://www.reddit.com/r/AppFlowy\"\n                    style=\"\n                      text-decoration: none;\n                      display: inline-block;\n                      width: 28px;\n                      height: 28px;\n                      text-align: center;\n                      line-height: 28px;\n                    \"\n                  >\n                    <img\n                      src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                      width=\"20\"\n                      height=\"20\"\n                      alt=\"Reddit\"\n                      style=\"border: 0; vertical-align: middle\"\n                    />\n                  </a>\n                </td>\n                <td style=\"padding-right: 12px\">\n                  <a\n                    href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n                    style=\"\n                      text-decoration: none;\n                      display: inline-block;\n                      width: 28px;\n                      height: 28px;\n                      text-align: center;\n                      line-height: 28px;\n                    \"\n                  >\n                    <img\n                      src=\"{{ cdnBaseUrl }}images/github.png\"\n                      width=\"20\"\n                      height=\"20\"\n                      alt=\"GitHub\"\n                      style=\"border: 0; vertical-align: middle\"\n                    />\n                  </a>\n                </td>\n                <td>\n                  <a\n                    href=\"https://discord.gg/9Q2xaN37tV\"\n                    style=\"\n                      text-decoration: none;\n                      display: inline-block;\n                      width: 28px;\n                      height: 28px;\n                      text-align: center;\n                      line-height: 28px;\n                    \"\n                  >\n                    <img\n                      src=\"{{ cdnBaseUrl }}images/discord.png\"\n                      width=\"20\"\n                      height=\"20\"\n                      alt=\"Discord\"\n                      style=\"border: 0; vertical-align: middle\"\n                    />\n                  </a>\n                </td>\n              </tr>\n            </table>\n          </td>\n        </tr>\n\n        <!-- Footer fourth item - copyright information -->\n        <tr>\n          <td align=\"center\">\n            <div\n              style=\"\n                color: #6f748c;\n                text-align: center;\n                font-family:\n                  &quot;SF Pro Text&quot;,\n                  -apple-system,\n                  BlinkMacSystemFont,\n                  &quot;Segoe UI&quot;,\n                  Roboto,\n                  Helvetica,\n                  Arial,\n                  sans-serif;\n                font-size: 12px;\n                font-style: normal;\n                font-weight: 400;\n                line-height: 18px;\n                letter-spacing: 0.1px;\n                margin: 0;\n                padding: 0;\n              \"\n            >\n              <!-- Copyright text (normal style) -->\n              <div\n                style=\"\n                  color: #6f748c;\n                  font-family:\n                    &quot;SF Pro Text&quot;,\n                    -apple-system,\n                    BlinkMacSystemFont,\n                    &quot;Segoe UI&quot;,\n                    Roboto,\n                    Helvetica,\n                    Arial,\n                    sans-serif;\n                  font-size: 12px;\n                  font-style: normal;\n                  font-weight: 400;\n                  line-height: 18px;\n                  letter-spacing: 0.1px;\n                \"\n              >\n                Copyright © 2025, AppFlowy Inc.\n              </div>\n              <div\n                style=\"\n                  color: #6f748c;\n                  font-family:\n                    &quot;SF Pro Text&quot;,\n                    -apple-system,\n                    BlinkMacSystemFont,\n                    &quot;Segoe UI&quot;,\n                    Roboto,\n                    Helvetica,\n                    Arial,\n                    sans-serif;\n                  font-size: 12px;\n                  font-style: normal;\n                  font-weight: 400;\n                  line-height: 18px;\n                  letter-spacing: 0.1px;\n                \"\n              >\n                Need Help?\n                <!-- Email link (with underline style) -->\n                <a\n                  href=\"mailto:support@appflowy.io\"\n                  style=\"\n                    color: #6f748c;\n                    font-family:\n                      &quot;SF Pro Text&quot;,\n                      -apple-system,\n                      BlinkMacSystemFont,\n                      &quot;Segoe UI&quot;,\n                      Roboto,\n                      Helvetica,\n                      Arial,\n                      sans-serif;\n                    font-size: 12px;\n                    font-style: normal;\n                    font-weight: 400;\n                    line-height: 18px;\n                    letter-spacing: 0.1px;\n                    text-decoration-line: underline;\n                    text-decoration-style: solid;\n                    text-decoration-skip-ink: none;\n                    text-decoration-thickness: auto;\n                    text-underline-offset: auto;\n                  \"\n                  >support@appflowy.io</a\n                >\n              </div>\n            </div>\n          </td>\n        </tr>\n      </table>\n    </td>\n  </tr>\n</table>\n"
  },
  {
    "path": "email_template/src/components/spacer.html",
    "content": "<script props>\n  // https://maizzle.com/docs/components/spacer\n  module.exports = {\n    height: props.height,\n    msoHeight: props[\"mso-height\"],\n  };\n</script>\n\n<if condition=\"height\">\n  <div\n    attributes\n    role=\"separator\"\n    style=\"{{ height && `line-height: ${height}` }};\n      {{ msoHeight && `mso-line-height-alt: ${msoHeight}` }};\n    \"\n  ></div>\n</if>\n<else>\n  <div role=\"separator\"></div>\n</else>\n"
  },
  {
    "path": "email_template/src/components/v-fill.html",
    "content": "<script props>\n  module.exports = {\n    width: props.width || '600px',\n    image: props.image || 'https://via.placeholder.com/600x400'\n  }\n</script>\n\n<!--[if mso]>\n<v:rect stroke=\"f\" fillcolor=\"none\" style=\"width: {{ width }}\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<v:fill type=\"frame\" src=\"{{{ image }}}\" />\n<v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text: true\"><div><![endif]-->\n<content />\n<!--[if mso]></div></v:textbox></v:rect><![endif]-->\n"
  },
  {
    "path": "email_template/src/components/v-image.html",
    "content": "<script props>\n  module.exports = {\n    width: props.width || '600px',\n    height: props.height || '400px',\n    image: props.image || 'https://via.placeholder.com/600x400'\n  }\n</script>\n\n<!--[if mso]>\n<v:image src=\"{{{ image }}}\" style=\"width: {{ width }}; height: {{ height }};\" xmlns:v=\"urn:schemas-microsoft-com:vml\" />\n<v:rect fill=\"f\" stroke=\"f\" style=\"position: absolute; width: {{ width }}; height: {{ height }};\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<v:textbox inset=\"0,0,0,0\"><div><![endif]-->\n<content />\n<!--[if mso]></div></v:textbox></v:rect><![endif]-->\n"
  },
  {
    "path": "email_template/src/css/resets.css",
    "content": "/*\n * Here is where you can add your global email CSS resets.\n *\n * We use a custom, email-specific CSS reset, instead\n * of Tailwind's web-optimized `base` layer.\n *\n * Styles defined here will be inlined.\n*/\n\nimg {\n  @apply max-w-full leading-none align-middle;\n}\n"
  },
  {
    "path": "email_template/src/css/tailwind.css",
    "content": "/* Your custom CSS resets for email */\n@import \"resets\";\n\n/* Tailwind CSS components */\n@import \"tailwindcss/components\";\n\n/**\n * @import here any custom CSS components - that is, CSS that\n * you'd want loaded before the Tailwind utilities, so the\n * utilities can still override them.\n*/\n\n/* Tailwind CSS utility classes */\n@import \"tailwindcss/utilities\";\n\n/* Your custom utility classes */\n@import \"utilities\";\n"
  },
  {
    "path": "email_template/src/css/utilities.css",
    "content": "/*\n * Here is where you can define your custom utility classes.\n *\n * We wrap them in the `utilities` @layer directive, so\n * that Tailwind moves them to the correct location.\n *\n * More info:\n * https://tailwindcss.com/docs/functions-and-directives#layer\n*/\n\n@layer utilities {\n  .break-word {\n    word-break: break-word;\n  }\n}\n"
  },
  {
    "path": "email_template/src/layouts/main.html",
    "content": "<!DOCTYPE {{{ page.doctype || 'html' }}}>\n<html lang=\"{{ page.language || 'en' }}\" xmlns:v=\"urn:schemas-microsoft-com:vml\">\n<head>\n  <meta charset=\"{{ page.charset || 'utf-8' }}\">\n  <meta name=\"x-apple-disable-message-reformatting\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <meta name=\"format-detection\" content=\"telephone=no, date=no, address=no, email=no, url=no\">\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <meta name=\"supported-color-schemes\" content=\"light dark\">\n  <!--[if mso]>\n  <noscript>\n    <xml>\n      <o:OfficeDocumentSettings xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n        <o:PixelsPerInch>96</o:PixelsPerInch>\n      </o:OfficeDocumentSettings>\n    </xml>\n  </noscript>\n  <style>\n    td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: \"Segoe UI\", sans-serif; mso-line-height-rule: exactly;}\n  </style>\n  <![endif]-->\n  <if condition=\"page.title\">\n    <title>{{{ page.title }}}</title>\n  </if>\n  <style>\n    {{{ page.css }}}\n  </style>\n  <stack name=\"head\" />\n</head>\n<body class=\"m-0 p-0 w-full [word-break:break-word] [-webkit-font-smoothing:antialiased] {{ page.bodyClass || '' }}\">\n  <if condition=\"page.preheader\">\n    <div class=\"hidden\">\n      {{{ page.preheader }}}\n      <each loop=\"item in Array.from(Array(150))\">&#8199;&#65279;&#847; </each>\n    </div>\n  </if>\n  <div role=\"article\" aria-roledescription=\"email\" aria-label=\"{{{ page.title || '' }}}\" lang=\"{{ page.language || 'en' }}\">\n    <content />\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "email_template/src/templates/access_request.html",
    "content": "---\ntitle: \"Request to join the workspace\"\npreheader: \"Approve a user's request to join the workspace.\"\nbodyClass: bg-purple-50\n---\n\n<x-main>\n  <div\n    class=\"bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black\"\n  >\n    <table align=\"center\">\n      <tr>\n        <td class=\"w-[552px] max-w-full\">\n          <div class=\"w-full text-center\">\n            <img\n              src=\"{{ userIconUrl }}\"\n              class=\"rounded-full overflow-hidden object-cover\"\n              width=\"48px\"\n              height=\"48px\"\n              alt=\"{{ userName }}\"\n            />\n          </div>\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"text-3xl font-bold\">{{ userName }}</span>\n            <span class=\"mx-2=1\">has requested access to </span>\n            <span class=\"text-3xl font-bold\">{{ workspaceName }}</span>\n          </p>\n          <x-divider space-x=\"20%\" />\n          <table align=\"center\">\n            <tr>\n              <td class=\"w-[60px]\">\n                <div\n                  style=\"border: 2px solid black\"\n                  class=\"rounded-2xl mr-2 w-[60px] h-[60px] bg-white overflow-hidden\"\n                >\n                  <img\n                    src=\"{{ workspaceIconURL }}\"\n                    class=\"overflow-hidden object-cover\"\n                    width=\"100%\"\n                    height=\"100%\"\n                    alt=\"{{ workspaceName }}\"\n                  />\n                </div>\n              </td>\n              <td>\n                <div class=\"font-bold mb-2\">{{ workspaceName }}</div>\n                <div class=\"text-sm text-slate-500\">\n                  {{ workspaceMembersCount }} members\n                </div>\n              </td>\n            </tr>\n          </table>\n          <x-button\n            align=\"center\"\n            class=\"hover:opacity-90 cursor-pointer !text-xl !leading-[20px] !bg-[#9327ff] !font-normal w-[60%] my-8 rounded-2xl\"\n            href=\"{{ approveUrl }}\"\n          >\n            <div class=\"font-medium text-[24px]\">Approve request</div>\n          </x-button>\n          <div\n            class=\"mx-auto leading-4.5 text-sm text-slate-500 text-center w-[70%]\"\n          >\n            By clicking \"Approve request\" above, the user will be added to the\n            workspace.\n          </div>\n          <x-divider space-x=\"20%\" />\n        </td>\n      </tr>\n      <tr>\n        <td class=\"text-center text-slate-600 text-xs px-6\">\n          <p class=\"m-0 mb-4 uppercase cursor-pointer\">\n            <a href=\"https://appflowy.io\">\n              <img\n                src=\"{{ cdnBaseUrl }}images/appflowy-logo.png\"\n                width=\"150px\"\n              />\n            </a>\n          </p>\n          <p class=\"m-0 text-sm text-black font-medium\">\n            Bring projects, knowledge, and teams together with the power of AI.\n          </p>\n\n          <p class=\"cursor-default\">\n            <a\n              href=\"https://twitter.com/appflowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://www.reddit.com/r/AppFlowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/github.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://discord.gg/9Q2xaN37tV\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/discord.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n          </p>\n        </td>\n      </tr>\n    </table>\n  </div>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/access_request_approved_notification.html",
    "content": "---\ntitle: \"Your access request has been approved\"\npreheader: \"Workspace access request approved notification\"\nbodyClass: bg-purple-50\n---\n\n<x-main>\n  <div\n    class=\"bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black\"\n  >\n    <table align=\"center\">\n      <tr>\n        <td class=\"w-[552px] max-w-full\">\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"mx-2=1\">Your request to access </span>\n            <span class=\"text-3xl font-bold\">{{ workspaceName }}</span>\n            <span class=\"mx-2=1\"> has been approved </span>\n          </p>\n          <x-divider space-x=\"20%\" />\n          <table align=\"center\">\n            <tr>\n              <td class=\"w-[60px]\">\n                <div\n                  style=\"border: 2px solid black\"\n                  class=\"rounded-2xl mr-2 w-[60px] h-[60px] bg-white overflow-hidden\"\n                >\n                  <img\n                    src=\"{{ workspaceIconURL }}\"\n                    class=\"overflow-hidden object-cover\"\n                    width=\"100%\"\n                    height=\"100%\"\n                    alt=\"{{ workspaceName }}\"\n                  />\n                </div>\n              </td>\n              <td>\n                <div class=\"font-bold mb-2\">{{ workspaceName }}</div>\n                <div class=\"text-sm text-slate-500\">\n                  {{ workspaceMembersCount }} members\n                </div>\n              </td>\n            </tr>\n          </table>\n          <x-button\n            align=\"center\"\n            class=\"hover:opacity-90 cursor-pointer !text-xl !leading-[20px] !bg-[#9327ff] !font-normal w-[60%] my-8 rounded-2xl\"\n            href=\"{{ launchWorkspaceUrl }}\"\n          >\n            <div class=\"font-medium text-[24px]\">View workspace</div>\n          </x-button>\n          <div\n            style=\"\n              margin-left: auto;\n              margin-right: auto;\n              width: 70%;\n              text-align: center;\n              font-size: 14px;\n              line-height: 18px;\n              color: #64748b;\n            \"\n          >\n            By clicking \"View workspace\" above, you confirm that you have read,\n            understood, and agreed to AppFlowy's\n            <a href=\"https://appflowy.io/terms/app\" style=\"color: #64748b\"\n              >Terms & Conditions</a\n            >\n            and\n            <a href=\"https://appflowy.io/privacy/app\" style=\"color: #64748b\"\n              >Privacy Policy</a\n            >.\n          </div>\n          <x-divider space-x=\"20%\" />\n        </td>\n      </tr>\n      <tr>\n        <td class=\"text-center text-slate-600 text-xs px-6\">\n          <p class=\"m-0 mb-4 uppercase cursor-pointer\">\n            <a href=\"https://appflowy.io\">\n              <img\n                src=\"{{ cdnBaseUrl }}images/appflowy-logo.png\"\n                width=\"150px\"\n              />\n            </a>\n          </p>\n          <p class=\"m-0 text-sm text-black font-medium\">\n            Bring projects, knowledge, and teams together with the power of AI.\n          </p>\n\n          <p class=\"cursor-default\">\n            <a\n              href=\"https://twitter.com/appflowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://www.reddit.com/r/AppFlowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/github.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n            <a\n              href=\"https://discord.gg/9Q2xaN37tV\"\n              class=\"text-indigo-700 [text-decoration:none] mr-4\"\n            >\n              <img\n                src=\"{{ cdnBaseUrl }}images/discord.png\"\n                width=\"20\"\n                alt=\"Maizzle\"\n              />\n            </a>\n          </p>\n        </td>\n      </tr>\n    </table>\n  </div>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/confirmation.html",
    "content": "---\ntitle: \"New sign up for AppFlowy\"\nbodyClass: #EEEEFC\n---\n\n<x-main>\n  <div style=\"display: none\">\n    To login to AppFlowy, follow this link @{{ .ConfirmationURL }}\n  </div>\n\n  <table\n    width=\"100%\"\n    border=\"0\"\n    cellpadding=\"0\"\n    cellspacing=\"0\"\n    bgcolor=\"#EEEEFC\"\n    style=\"\n      font-family:\n        Google Sans,\n        Robot,\n        Arial,\n        sans-serif;\n      padding: 24px;\n      color: #000000;\n    \"\n  >\n    <tr>\n      <td align=\"center\" valign=\"top\">\n        <table\n          width=\"600\"\n          border=\"0\"\n          cellpadding=\"0\"\n          cellspacing=\"0\"\n          bgcolor=\"#ffffff\"\n          style=\"border-radius: 12px; padding: 32px; margin: 0 auto\"\n        >\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <img\n                src=\"{{ cdnBaseUrl }}images/appflowy.png\"\n                width=\"32\"\n                height=\"32\"\n                style=\"border: 0; display: block; margin: 0 auto\"\n              />\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 8px\">\n              <h1\n                style=\"\n                  font-size: 24px;\n                  font-weight: bold;\n                  margin: 0;\n                  color: #000000;\n                \"\n              >\n                Login for AppFlowy\n              </h1>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <p style=\"font-size: 16px; line-height: 1.5; margin: 0\">\n                We have received a request to confirm your AppFlowy account.\n              </p>\n              <p style=\"font-size: 16px; line-height: 1.5; margin: 2px 0 0 0\">\n                You can log in using either of the following options:\n              </p>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 12px\">\n              <table\n                width=\"100%\"\n                border=\"0\"\n                cellpadding=\"0\"\n                cellspacing=\"0\"\n                bgcolor=\"#F8FAFF\"\n                style=\"border-radius: 20px; padding: 24px 20px\"\n              >\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px\">\n                    <h2\n                      style=\"\n                        font-size: 16px;\n                        font-weight: bold;\n                        margin: 0;\n                        color: #000000;\n                      \"\n                    >\n                      Option 1: Magic Link (Fast & Easy)\n                    </h2>\n                    <p\n                      style=\"\n                        font-size: 14px;\n                        color: #6f748c;\n                        margin: 10px 0 0 0;\n                      \"\n                    >\n                      Click the button or link below to log in instantly\n                    </p>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px\">\n                    <table\n                      border=\"0\"\n                      cellpadding=\"0\"\n                      cellspacing=\"0\"\n                      style=\"margin: 0 auto\"\n                    >\n                      <tr>\n                        <td\n                          align=\"center\"\n                          bgcolor=\"#9327FF\"\n                          style=\"border-radius: 10px; padding: 10px 16px\"\n                        >\n                          <a\n                            href=\"@{{ .ConfirmationURL }}\"\n                            style=\"\n                              color: #ffffff;\n                              font-weight: 600;\n                              font-size: 14px;\n                              text-decoration: none;\n                              display: inline-block;\n                            \"\n                            >Login to AppFlowy</a\n                          >\n                        </td>\n                      </tr>\n                    </table>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\">\n                    <p\n                      style=\"\n                        padding-bottom: 16px;\n                        font-size: 12px;\n                        color: #6f748c;\n                        margin: 0;\n                      \"\n                    >\n                      Or paste this into your browser:\n                    </p>\n                    <p style=\"max-width: 384px; margin: 0\">\n                      <a\n                        href=\"@{{ .ConfirmationURL }}\"\n                        style=\"\n                          font-size: 12px;\n                          text-decoration: none;\n                          text-align: center;\n                          color: #6f748c;\n                          font-weight: bold;\n                          margin: 0;\n                          word-break: break-all;\n                        \"\n                      >\n                        @{{ .ConfirmationURL }}\n                      </a>\n                    </p>\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <table\n                width=\"100%\"\n                border=\"0\"\n                cellpadding=\"0\"\n                cellspacing=\"0\"\n                bgcolor=\"#F8FAFF\"\n                style=\"border-radius: 20px; padding: 24px 20px\"\n              >\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px\">\n                    <h2\n                      style=\"\n                        font-size: 16px;\n                        font-weight: bold;\n                        margin: 0;\n                        color: #000000;\n                      \"\n                    >\n                      Option 2: One-Time Password (OTP)\n                    </h2>\n                    <p\n                      style=\"\n                        font-size: 14px;\n                        color: #6f748c;\n                        margin: 10px 0 0 0;\n                      \"\n                    >\n                      Prefer to enter a code instead? Use the one-time code\n                      below\n                    </p>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\">\n                    <span\n                      style=\"\n                        background-color: #ffffff;\n                        padding: 8px;\n                        border-radius: 6px;\n                        font-weight: 600;\n                        font-size: 16px;\n                        display: inline-block;\n                      \"\n                      >@{{ .Token }}</span\n                    >\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                This code and magic link will expire in 5 minutes for security\n                reasons.\n              </p>\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                If you didn't initiate this login, you can safely ignore this\n                email. No action is needed.\n              </p>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\">\n              <p\n                style=\"\n                  border-top: 1px solid #e4e8f5;\n                  padding-top: 24px;\n                  font-size: 12px;\n                  color: #6f748c;\n                  margin: 0 0 15px 0;\n                \"\n              >\n                Bring projects, knowledge, and teams together with the power of\n                AI.\n              </p>\n\n              <p style=\"margin: 0 0 15px 0\">\n                <a\n                  href=\"https://discord.gg/9Q2xaN37tV\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/discord.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Discord\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/github.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"GitHub\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://www.reddit.com/r/AppFlowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Reddit\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://twitter.com/appflowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Twitter\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://www.youtube.com/@AppFlowyHQ\"\n                  style=\"text-decoration: none; display: inline-block\"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/youtube.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Youtube\"\n                    style=\"border: 0\"\n                  />\n                </a>\n              </p>\n\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0 0 10px 0\">\n                Copyright © 2025, AppFlowy Inc.\n              </p>\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                Need Help?\n                <a href=\"mailto:support@appflowy.io\" style=\"color: #6f748c\"\n                  >support@appflowy.io</a\n                >\n              </p>\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n  </table>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/import_data_fail.html",
    "content": "---\ntitle: \"Workspace Import Failed\"\npreheader: \"There was an issue with your workspace import\"\nbodyClass: bg-purple-50\n---\n\n<x-main>\n  <div class=\"bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black\">\n    <table align=\"center\">\n      <tr>\n        <td class=\"w-[622px] max-w-full text-center\">\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"text-3xl font-bold\">Notion Import Failed</span>\n\n          </p>\n\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"mx-2=1 text-[#fb006d]\">{{ error }}</span>\n          </p>\n\n          <div class=\"mx-auto  leading-4.5 text-sm text-slate-500 text-center w-[70%]\">\n            Join our Discord <a href=\"https://discord.gg/9Q2xaN37tV\" class=\"text-[#9327ff]\">server</a> to get quick help\n            or <a href=\"https://github.com/AppFlowy-IO/AppFlowy/issues/new/choose\" class=\"text-[#9327ff]\">\n            report</a> the issue on GitHub\n          </div>\n\n          <x-divider space-x=\"20%\"/>\n        </td>\n      </tr>\n      <tr>\n        <td class=\"text-center text-slate-600 text-xs px-6\">\n          <p class=\"m-0 mb-4 uppercase cursor-pointer\">\n            <a href=\"https://appflowy.io\">\n              <img src=\"{{ cdnBaseUrl }}images/appflowy-logo.png\" width=\"150px\">\n            </a>\n          </p>\n          <p class=\"m-0 text-sm text-black font-medium\">\n            Bring projects, knowledge, and teams together with the power of AI.\n          </p>\n\n          <p class=\"cursor-default\">\n            <a href=\"https://twitter.com/appflowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/twitter.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://www.reddit.com/r/AppFlowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/reddit.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/github.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://discord.gg/9Q2xaN37tV\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/discord.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n          </p>\n        </td>\n      </tr>\n    </table>\n  </div>\n</x-main>\n\n"
  },
  {
    "path": "email_template/src/templates/import_data_success.html",
    "content": "---\ntitle: \"Workspace Import Success\"\npreheader: \"Your workspace import was successful\"\nbodyClass: bg-purple-50\n---\n\n<x-main>\n  <div class=\"bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black\">\n    <table align=\"center\">\n      <tr>\n        <td class=\"w-[582px] max-w-full\">\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"text-3xl font-bold\">Notion Import Complete</span>\n\n          </p>\n\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"mx-2=1\">Your Notion data has been successfully imported into</span>\n          </p>\n          <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n            <span class=\"text-3xl font-bold\">{{ workspaceName }}</span>\n          </p>\n          <x-divider space-x=\"20%\"/>\n          <table align=\"center\">\n            <tr>\n              <td class=\"w-[60px]\">\n                <div\n                  style=\"border: 2px solid black;\"\n                  class=\"rounded-2xl mr-2 w-[60px] p-2 h-[60px] bg-white overflow-hidden\">\n                  <img src=\"{{ cdnBaseUrl }}images/appflowy.png\"\n                       class=\"overflow-hidden object-cover\"\n                       width=\"100%\" height=\"100%\"\n                       alt=\"{{ workspaceName }}\">\n                </div>\n\n              </td>\n              <td>\n                <div class=\"font-bold mb-2\">\n                  {{ workspaceName }}\n                </div>\n                <div class=\"text-sm text-slate-500\"> 1 member</div>\n\n              </td>\n            </tr>\n          </table>\n          <x-button align=\"center\"\n                    target=\"_blank\"\n                    class=\"hover:opacity-90 cursor-pointer !text-xl !leading-[20px] !bg-[#9327ff] !font-normal w-[60%] my-8 rounded-2xl\"\n                    href=\"https://appflowy.io/download\">\n\n            <div class=\"font-medium text-[24px]\">\n              Download\n            </div>\n          </x-button>\n          <x-divider space-x=\"20%\"/>\n        </td>\n      </tr>\n      <tr>\n        <td class=\"text-center text-slate-600 text-xs px-6\">\n          <p class=\"m-0 mb-4 uppercase cursor-pointer\">\n            <a href=\"https://appflowy.io\">\n              <img src=\"{{ cdnBaseUrl }}images/appflowy-logo.png\" width=\"150px\">\n            </a>\n          </p>\n          <p class=\"m-0 text-sm text-black font-medium\">\n            Bring projects, knowledge, and teams together with the power of AI.\n          </p>\n\n          <p class=\"cursor-default\">\n            <a href=\"https://twitter.com/appflowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/twitter.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://www.reddit.com/r/AppFlowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/reddit.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/github.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n            <a href=\"https://discord.gg/9Q2xaN37tV\"\n               class=\"text-indigo-700 [text-decoration:none] mr-4\">\n              <img src=\"{{ cdnBaseUrl }}images/discord.png\" width=\"20\" alt=\"Maizzle\">\n            </a>\n          </p>\n        </td>\n      </tr>\n    </table>\n  </div>\n</x-main>"
  },
  {
    "path": "email_template/src/templates/magic_link.html",
    "content": "---\ntitle: \"Login for AppFlowy\"\nbodyClass: #EEEEFC\n---\n\n<x-main>\n  <div style=\"display: none\">\n    To login to AppFlowy, follow this link @{{ .ConfirmationURL }}\n  </div>\n\n  <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#EEEEFC\" style=\"font-family: Google Sans, Robot, Arial, sans-serif; padding: 24px; color: #000000;\">\n    <tr>\n      <td align=\"center\" valign=\"top\">\n        <table width=\"600\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#ffffff\" style=\"border-radius: 12px; padding: 32px; margin: 0 auto;\">\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px;\">\n              <img src=\"{{ cdnBaseUrl }}images/appflowy.png\" width=\"32\" height=\"32\" style=\"border: 0; display: block; margin: 0 auto;\" />\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 8px;\">\n              <h1 style=\"font-size: 24px; font-weight: bold; margin: 0; color: #000000;\">Login for AppFlowy</h1>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px;\">\n              <p style=\"font-size: 16px; line-height: 1.5; margin: 0;\">We received a request to log in to your AppFlowy account.</p>\n              <p style=\"font-size: 16px; line-height: 1.5; margin: 2px 0 0 0;\">You can log in using either of the following options:</p>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 12px;\">\n              <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#F8FAFF\" style=\"border-radius: 20px; padding: 24px 20px;\">\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px;\">\n                    <h2 style=\"font-size: 16px; font-weight: bold; margin: 0; color: #000000;\">Option 1: Magic Link (Fast & Easy)</h2>\n                    <p style=\"font-size: 14px; color: #6F748C; margin: 10px 0 0 0;\">Click the button or link below to log in instantly</p>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px;\">\n                    <table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 0 auto;\">\n                      <tr>\n                        <td align=\"center\" bgcolor=\"#9327FF\" style=\"border-radius: 10px; padding: 10px 16px;\">\n                          <a href=\"@{{ .ConfirmationURL }}\" style=\"color: #ffffff; font-weight: 600; font-size: 14px; text-decoration: none; display: inline-block;\">Login to AppFlowy</a>\n                        </td>\n                      </tr>\n                    </table>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\">\n                    <p style=\"padding-bottom: 16px; font-size: 12px; color: #6F748C; margin: 0;\">Or paste this into your browser:</p>\n                    <p style=\"max-width: 384px;margin: 0;\">\n                      <a href=\"@{{ .ConfirmationURL }}\" style=\"font-size: 12px; text-decoration: none; text-align: center; color: #6F748C; font-weight: bold; margin: 0; word-break: break-all;\">\n                        @{{ .ConfirmationURL }}\n                      </a>\n                    </p>\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px;\">\n              <table width=\"100%\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"#F8FAFF\" style=\"border-radius: 20px; padding: 24px 20px;\">\n                <tr>\n                  <td align=\"center\" style=\"padding-bottom: 16px;\">\n                    <h2 style=\"font-size: 16px; font-weight: bold; margin: 0; color: #000000;\">Option 2: One-Time Password (OTP)</h2>\n                    <p style=\"font-size: 14px; color: #6F748C; margin: 10px 0 0 0;\">Prefer to enter a code instead? Use the one-time code below</p>\n                  </td>\n                </tr>\n\n                <tr>\n                  <td align=\"center\">\n                    <span style=\"background-color: #ffffff; padding: 8px; border-radius: 6px; font-weight: 600; font-size: 16px; display: inline-block;\">@{{ .Token }}</span>\n                  </td>\n                </tr>\n              </table>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px;\">\n              <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">This code and magic link will expire in 5 minutes for security reasons.</p>\n              <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">If you didn't initiate this login, you can safely ignore this email. No action is needed.</p>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\">\n              <p style=\"border-top: 1px solid #E4E8F5; padding-top: 24px; font-size: 12px; color: #6F748C; margin: 0 0 15px 0;\">Bring projects, knowledge, and teams together with the power of AI.</p>\n\n              <p style=\"margin: 0 0 15px 0;\">\n                <a href=\"https://discord.gg/9Q2xaN37tV\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                  <img src=\"{{ cdnBaseUrl }}images/discord.png\" width=\"20\" height=\"20\" alt=\"Discord\" style=\"border: 0;\" />\n                </a>\n                <a href=\"https://github.com/AppFlowy-IO/AppFlowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                  <img src=\"{{ cdnBaseUrl }}images/github.png\" width=\"20\" height=\"20\" alt=\"GitHub\" style=\"border: 0;\" />\n                </a>\n                <a href=\"https://www.reddit.com/r/AppFlowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                  <img src=\"{{ cdnBaseUrl }}images/reddit.png\" width=\"20\" height=\"20\" alt=\"Reddit\" style=\"border: 0;\" />\n                </a>\n                <a href=\"https://twitter.com/appflowy\" style=\"text-decoration: none; display: inline-block; margin: 0 10px 0 0;\">\n                  <img src=\"{{ cdnBaseUrl }}images/twitter.png\" width=\"20\" height=\"20\" alt=\"Twitter\" style=\"border: 0;\" />\n                </a>\n                <a href=\"https://www.youtube.com/@AppFlowyHQ\" style=\"text-decoration: none; display: inline-block;\">\n                  <img src=\"{{ cdnBaseUrl }}images/youtube.png\" width=\"20\" height=\"20\" alt=\"Youtube\" style=\"border: 0;\" />\n                </a>\n              </p>\n\n              <p style=\"font-size: 12px; color: #6F748C; margin: 0 0 10px 0;\">Copyright © 2025, AppFlowy Inc.</p>\n              <p style=\"font-size: 12px; color: #6F748C; margin: 0;\">\n                Need Help? <a href=\"mailto:support@appflowy.io\" style=\"color: #6F748C;\">support@appflowy.io</a>\n              </p>\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n  </table>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/page_mention_notification.html",
    "content": "---\nbodyClass: bg-[#EEEEFC]\n---\n\n<x-main>\n  <div class=\"bg-[#EEEEFC] font-helvetica px-4 py-12 text-black\">\n    <table align=\"center\">\n      <tr>\n        <td\n          class=\"w-[600px] max-w-full bg-white rounded-2xl px-16 py-12 shadow-lg\"\n        >\n          <p class=\"w-full text-center mb-6\">\n            <img\n              src=\"{{ mentionerIconUrl }}\"\n              class=\"rounded-full\"\n              width=\"64\"\n              height=\"64\"\n              alt=\"AppFlowy\"\n            />\n          </p>\n\n          <p class=\"w-full text-center text-[32px] mb-8\">\n            <span class=\"text-3xl font-bold\">{{ mentionerName }}</span>\n            <span class=\"mx-2=1\">has mentioned you in </span>\n            <span class=\"text-3xl font-bold\">{{ mentionedPageName }}</span>\n          </p>\n\n          <p\n            class=\"w-full text-center text-[16px]\"\n            style=\"color: var(--Text-secondary, #6f748c)\"\n          >\n            {{ mentionedAt }}\n          </p>\n          <p\n            class=\"w-full text-center text-[16px] mb-8\"\n            style=\"color: var(--Text-secondary, #6f748c)\"\n          >\n            {{ workspaceName }} / ... / {{ mentionedPageName }}\n          </p>\n\n          <div class=\"text-center mb-8\">\n            <x-button\n              align=\"center\"\n              target=\"_blank\"\n              class=\"hover:opacity-90 cursor-pointer !text-base !leading-[20px] !bg-[#9327FF] rounded-lg inline-block\"\n              style=\"padding: 12px 36px; font-weight: 500\"\n              href=\"{{ launchWorkspaceUrl }}\"\n            >\n              Go to page\n            </x-button>\n          </div>\n\n          <!-- Divider -->\n          <hr class=\"border-t border-gray-100 my-8\" style=\"opacity: 0.4\" />\n\n          <!-- Footer content inside body -->\n          <div class=\"text-center\">\n            <p\n              class=\"text-sm m-0 mb-6\"\n              style=\"color: var(--Text-secondary, #6f748c)\"\n            >\n              Bring projects, knowledge, and teams together with the power of\n              AI.\n            </p>\n\n            <p class=\"m-0 mb-4\">\n              <a\n                href=\"https://discord.gg/9Q2xaN37tV\"\n                class=\"[text-decoration:none] mx-2\"\n              >\n                <img\n                  src=\"{{ cdnBaseUrl }}images/discord.png\"\n                  width=\"24\"\n                  height=\"24\"\n                  alt=\"Discord\"\n                />\n              </a>\n              <a\n                href=\" https://github.com/AppFlowy-IO/AppFlowy\"\n                class=\"text-gray-600 [text-decoration:none] mx-2\"\n              >\n                <img\n                  src=\"{{ cdnBaseUrl }}images/github.png\"\n                  width=\"24\"\n                  height=\"24\"\n                  alt=\"GitHub\"\n                />\n              </a>\n              <a\n                href=\" https://www.reddit.com/r/AppFlowy\"\n                class=\"text-gray-600 [text-decoration:none] mx-2\"\n              >\n                <img\n                  src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                  width=\"24\"\n                  height=\"24\"\n                  alt=\"Reddit\"\n                />\n              </a>\n              <a\n                href=\" https://twitter.com/appflowy\"\n                class=\"[text-decoration:none] mx-2\"\n              >\n                <img\n                  src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                  width=\"24\"\n                  height=\"24\"\n                  alt=\"Twitter\"\n                />\n              </a>\n              <a\n                href=\" https://www.youtube.com/@appflowy\"\n                class=\"text-gray-600 [text-decoration:none] mx-2\"\n              >\n                <img\n                  src=\"{{ cdnBaseUrl }}images/youtube.png\"\n                  width=\"24\"\n                  height=\"24\"\n                  alt=\"YouTube\"\n                />\n              </a>\n            </p>\n\n            <p\n              class=\"m-0\"\n              style=\"\n                color: var(--Text-secondary, #6f748c);\n                font-size: 12px;\n                font-family:\n                  SF Pro Text,\n                  Arial,\n                  sans-serif;\n                font-weight: 400;\n                line-height: 18px;\n                letter-spacing: 0.1px;\n                word-wrap: break-word;\n              \"\n            >\n              Copyright © 2025, AppFlowy Inc.\n            </p>\n            <p\n              class=\"m-0\"\n              style=\"\n                color: var(--Text-secondary, #6f748c);\n                font-size: 12px;\n                font-family:\n                  SF Pro Text,\n                  Arial,\n                  sans-serif;\n                font-weight: 400;\n                line-height: 18px;\n                letter-spacing: 0.1px;\n                word-wrap: break-word;\n              \"\n            >\n              Need Help?\n              <a\n                href=\"mailto:support@appflowy.io\"\n                style=\"\n                  color: var(--Text-secondary, #6f748c);\n                  font-size: 12px;\n                  font-family:\n                    SF Pro Text,\n                    Arial,\n                    sans-serif;\n                  font-weight: 400;\n                  text-decoration: underline;\n                \"\n              >\n                support@appflowy.io\n              </a>\n            </p>\n            <!-- Transparent timestamp to prevent email collapse -->\n            <span\n              style=\"\n                color: transparent;\n                font-size: 1px;\n                line-height: 1px;\n                display: block;\n              \"\n            >\n              {{ timestamp }}\n            </span>\n          </div>\n        </td>\n      </tr>\n    </table>\n  </div>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/recovery.html",
    "content": "---\ntitle: \"AppFlowy Password Recovery\"\nbodyClass: #EEEEFC\n---\n\n<x-main>\n  <div style=\"display: none\">\n    To reset your password, enter the code below in the app:\n  </div>\n\n  <table\n    width=\"100%\"\n    border=\"0\"\n    cellpadding=\"0\"\n    cellspacing=\"0\"\n    bgcolor=\"#EEEEFC\"\n    style=\"\n      font-family:\n        Google Sans,\n        Robot,\n        Arial,\n        sans-serif;\n      padding: 24px;\n      color: #000000;\n    \"\n  >\n    <tr>\n      <td align=\"center\" valign=\"top\">\n        <table\n          width=\"600\"\n          border=\"0\"\n          cellpadding=\"0\"\n          cellspacing=\"0\"\n          bgcolor=\"#ffffff\"\n          style=\"border-radius: 12px; padding: 32px; margin: 0 auto\"\n        >\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <img\n                src=\"{{ cdnBaseUrl }}images/appflowy.png\"\n                width=\"32\"\n                height=\"32\"\n                style=\"border: 0; display: block; margin: 0 auto\"\n              />\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 8px\">\n              <h1\n                style=\"\n                  font-size: 24px;\n                  font-weight: bold;\n                  margin: 0;\n                  color: #000000;\n                \"\n              >\n                Reset your password\n              </h1>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <p style=\"font-size: 16px; line-height: 1.5; margin: 0\">\n                Someone recently requested a password reset for your AppFlowy\n                account. If this was you, use the following verification code.\n              </p>\n            </td>\n          </tr>\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <span\n                style=\"\n                  background-color: #e4e8f5;\n                  padding: 8px;\n                  border-radius: 6px;\n                  font-weight: 600;\n                  font-size: 16px;\n                  display: inline-block;\n                \"\n                >@{{ .Token }}</span\n              >\n            </td>\n          </tr>\n          <tr>\n            <td align=\"center\" style=\"padding-bottom: 24px\">\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                This code will expire in 5 minutes for security reasons.\n              </p>\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                If you didn't initiate this recovery, you can safely ignore this\n                email. No action is needed.\n              </p>\n            </td>\n          </tr>\n\n          <tr>\n            <td align=\"center\">\n              <p\n                style=\"\n                  border-top: 1px solid #e4e8f5;\n                  padding-top: 24px;\n                  font-size: 12px;\n                  color: #6f748c;\n                  margin: 0 0 15px 0;\n                \"\n              >\n                Bring projects, knowledge, and teams together with the power of\n                AI.\n              </p>\n\n              <p style=\"margin: 0 0 15px 0\">\n                <a\n                  href=\"https://discord.gg/9Q2xaN37tV\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/discord.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Discord\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/github.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"GitHub\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://www.reddit.com/r/AppFlowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/reddit.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Reddit\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://twitter.com/appflowy\"\n                  style=\"\n                    text-decoration: none;\n                    display: inline-block;\n                    margin: 0 10px 0 0;\n                  \"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/twitter.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Twitter\"\n                    style=\"border: 0\"\n                  />\n                </a>\n                <a\n                  href=\"https://www.youtube.com/@AppFlowyHQ\"\n                  style=\"text-decoration: none; display: inline-block\"\n                >\n                  <img\n                    src=\"{{ cdnBaseUrl }}images/youtube.png\"\n                    width=\"20\"\n                    height=\"20\"\n                    alt=\"Youtube\"\n                    style=\"border: 0\"\n                  />\n                </a>\n              </p>\n\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0 0 10px 0\">\n                Copyright © 2025, AppFlowy Inc.\n              </p>\n              <p style=\"font-size: 12px; color: #6f748c; margin: 0\">\n                Need Help?\n                <a href=\"mailto:support@appflowy.io\" style=\"color: #6f748c\"\n                  >support@appflowy.io</a\n                >\n              </p>\n            </td>\n          </tr>\n        </table>\n      </td>\n    </tr>\n  </table>\n</x-main>\n"
  },
  {
    "path": "email_template/src/templates/workspace_invitation.html",
    "content": "---\ntitle: \"Confirm to join the workspace\"\npreheader: \"Please confirm your email address to join the workspace.\"\nbodyClass: bg-purple-50\n---\n\n<x-main>\n    <div class=\"bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black\">\n        <table align=\"center\">\n            <tr>\n                <td class=\"w-[552px] max-w-full\">\n                    <div class=\"w-full text-center\">\n                        <img src=\"{{ userIconUrl }}\" class=\"rounded-full overflow-hidden object-cover\" width=\"48px\"\n                             height=\"48px\"\n                             alt=\"{{ userName }}\">\n                    </div>\n                    <p class=\"w-full text-center break-words whitespace-normal text-2xl\">\n                        <span class=\"text-3xl font-bold\">{{ userName }}</span>\n                        <span class=\"mx-2=1\">invited you to </span>\n                        <span class=\"text-3xl font-bold\">{{ workspaceName }}</span>\n                    </p>\n                    <x-divider space-x=\"20%\"/>\n                    <table align=\"center\">\n                        <tr>\n                            <td class=\"w-[60px]\">\n                                <div\n                                        style=\"border: 2px solid black;\"\n                                        class=\"rounded-2xl mr-2 w-[60px] h-[60px] bg-white overflow-hidden\">\n                                    <img src=\"{{ workspaceIconURL }}\"\n                                         class=\"overflow-hidden object-cover\"\n                                         width=\"100%\" height=\"100%\"\n                                         alt=\"{{ workspaceName }}\">\n                                </div>\n\n                            </td>\n                            <td>\n                                <div class=\"font-bold mb-2\">\n                                    {{ workspaceName }}\n                                </div>\n                                <div class=\"text-sm text-slate-500\"> {{ workspaceMembersCount }} members</div>\n                            </td>\n                        </tr>\n                    </table>\n                    <x-button align=\"center\"\n                              class=\"hover:opacity-90 cursor-pointer !text-xl !leading-[20px] !bg-[#9327ff] !font-normal w-[60%] my-8 rounded-2xl\"\n                              href=\"{{ acceptUrl }}\">\n\n                        <div class=\"font-medium text-[24px]\">\n                            Join workspace\n                            <div class=\"text-xs\">require v0.5.6+ to continue</div>\n                        </div>\n                    </x-button>\n                    <div class=\"mx-auto  leading-4.5 text-sm text-slate-500 text-center w-[70%]\">\n                        By clicking \"Join workspace\" above, you confirm that you have read, understood, and agreed to\n                        AppFlowy's <a href=\"https://appflowy.io/terms/app\" class=\"text-slate-500\">Terms &\n                        Conditions</a>\n                        and <a class=\"text-slate-500\" href=\"https://appflowy.io/privacy/app\">Privacy\n                        Policy</a>.\n                    </div>\n                    <x-divider space-x=\"20%\"/>\n                </td>\n            </tr>\n            <tr>\n                <td class=\"text-center text-slate-600 text-xs px-6\">\n                    <p class=\"m-0 mb-4 uppercase cursor-pointer\">\n                        <a href=\"https://appflowy.io\">\n                            <img src=\"{{ cdnBaseUrl }}images/appflowy-logo.png\" width=\"150px\">\n                        </a>\n                    </p>\n                    <p class=\"m-0 text-sm text-black font-medium\">\n                        Bring projects, knowledge, and teams together with the power of AI.\n                    </p>\n\n                    <p class=\"cursor-default\">\n                        <a href=\"https://twitter.com/appflowy\"\n                           class=\"text-indigo-700 [text-decoration:none] mr-4\">\n                            <img src=\"{{ cdnBaseUrl }}images/twitter.png\" width=\"20\" alt=\"Maizzle\">\n                        </a>\n                        <a href=\"https://www.reddit.com/r/AppFlowy\"\n                           class=\"text-indigo-700 [text-decoration:none] mr-4\">\n                            <img src=\"{{ cdnBaseUrl }}images/reddit.png\" width=\"20\" alt=\"Maizzle\">\n                        </a>\n                        <a href=\"https://github.com/AppFlowy-IO/AppFlowy\"\n                           class=\"text-indigo-700 [text-decoration:none] mr-4\">\n                            <img src=\"{{ cdnBaseUrl }}images/github.png\" width=\"20\" alt=\"Maizzle\">\n                        </a>\n                        <a href=\"https://discord.gg/9Q2xaN37tV\"\n                           class=\"text-indigo-700 [text-decoration:none] mr-4\">\n                            <img src=\"{{ cdnBaseUrl }}images/discord.png\" width=\"20\" alt=\"Maizzle\">\n                        </a>\n                    </p>\n                </td>\n            </tr>\n        </table>\n    </div>\n</x-main>"
  },
  {
    "path": "email_template/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  theme: {\n    screens: {\n      sm: { max: '600px' },\n      xs: { max: '425px' },\n    },\n    extend: {\n      spacing: {\n        screen: '100vw',\n        full: '100%',\n        0: '0',\n        0.5: '2px',\n        1: '4px',\n        1.5: '6px',\n        2: '8px',\n        2.5: '10px',\n        3: '12px',\n        3.5: '14px',\n        4: '16px',\n        4.5: '18px',\n        5: '20px',\n        5.5: '22px',\n        6: '24px',\n        6.5: '26px',\n        7: '28px',\n        7.5: '30px',\n        8: '32px',\n        8.5: '34px',\n        9: '36px',\n        9.5: '38px',\n        10: '40px',\n        11: '44px',\n        12: '48px',\n        14: '56px',\n        16: '64px',\n        20: '80px',\n        24: '96px',\n        28: '112px',\n        32: '128px',\n        36: '144px',\n        40: '160px',\n        44: '176px',\n        48: '192px',\n        52: '208px',\n        56: '224px',\n        60: '240px',\n        64: '256px',\n        72: '288px',\n        80: '320px',\n        96: '384px',\n      },\n      borderRadius: {\n        none: '0px',\n        sm: '2px',\n        DEFAULT: '4px',\n        md: '6px',\n        lg: '8px',\n        xl: '12px',\n        '2xl': '16px',\n        '3xl': '24px',\n      },\n      boxShadow: {\n        sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',\n        DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',\n        md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',\n        lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',\n        xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',\n        '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',\n        inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)',\n      },\n      fontFamily: {\n        sans: ['ui-sans-serif', 'system-ui', '-apple-system', '\"Segoe UI\"', 'sans-serif'],\n        serif: ['ui-serif', 'Georgia', 'Cambria', '\"Times New Roman\"', 'Times', 'serif'],\n        mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'],\n        helvetica: ['Helvetica', 'ui-sans-serif', 'system-ui', '-apple-system', '\"Segoe UI\"', 'sans-serif'],\n      },\n      fontSize: {\n        0: '0',\n        xxs: '11px',\n        xs: '12px',\n        '2xs': '13px',\n        sm: '14px',\n        '2sm': '15px',\n        base: '16px',\n        lg: '18px',\n        xl: '20px',\n        '2xl': '24px',\n        '3xl': '30px',\n        '4xl': '36px',\n        '5xl': '48px',\n        '6xl': '60px',\n        '7xl': '72px',\n        '8xl': '96px',\n        '9xl': '128px',\n      },\n      letterSpacing: theme => ({\n        ...theme('width'),\n      }),\n      lineHeight: theme => ({\n        ...theme('width'),\n      }),\n      maxWidth: theme => ({\n        ...theme('width'),\n        xs: '160px',\n        sm: '192px',\n        md: '224px',\n        lg: '256px',\n        xl: '288px',\n        '2xl': '336px',\n        '3xl': '384px',\n        '4xl': '448px',\n        '5xl': '512px',\n        '6xl': '576px',\n        '7xl': '640px',\n      }),\n      minHeight: theme => ({\n        ...theme('width'),\n      }),\n      minWidth: theme => ({\n        ...theme('width'),\n      }),\n    },\n  },\n  corePlugins: {\n    preflight: false,\n    backgroundOpacity: false,\n    borderOpacity: false,\n    divideOpacity: false,\n    placeholderOpacity: false,\n    textOpacity: false,\n  },\n  plugins: [\n    require('tailwindcss-mso'),\n    require('tailwindcss-box-shadow'),\n    require('tailwindcss-email-variants'),\n  ],\n};\n"
  },
  {
    "path": "env.deploy.secret.example",
    "content": "# Copy this file to .env.deploy.secret and replace the placeholder values with\n# your actual production secrets. Use this with deploy.env as your base environment.\n#\n# Usage:\n#   cp env.deploy.secret.example .env.deploy.secret\n#   # Edit .env.deploy.secret with your real production values\n#   ./script/generate_env.sh\n#   # Select \"deploy.env\" when prompted\n#\n\n# OAuth Providers (optional)\nGOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=\nGOTRUE_EXTERNAL_GOOGLE_SECRET=\n\n# AI Features (optional)\nAI_OPENAI_API_KEY="
  },
  {
    "path": "env.dev.secret.example",
    "content": "# Copy this file to .env.dev.secret and replace the placeholder values with your\n# actual secrets. The generate_env.sh script will merge these values into your\n# chosen base environment file (deploy.env or dev.env).\n#\n# Usage:\n#   cp env.dev.secret.example .env.dev.secret\n#   # Edit .env.dev.secret with your real values\n#   ./script/generate_env.sh\n#   # Select \"dev.env\" when prompted\n\n# OAuth Providers (optional)\nGOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=\nGOTRUE_EXTERNAL_GOOGLE_SECRET=\n\n# AI Features (optional)\nAI_OPENAI_API_KEY="
  },
  {
    "path": "external_proxy_config/nginx/appflowy.site.conf",
    "content": "# You can use this site configuration as a template if you want to use an external reverse proxy\n# as opposed to the nginx service in the docker compose.\n# Remember to expose the ports of the docker compose services based on the configuration here, and\n# update the Nginx configuration as necessary if you map the services to different ports.\n\nmap $http_upgrade $connection_upgrade {\n    default upgrade;\n    '' close;\n}\n\nserver {\n    server_name appflowy-cloud.example.com;\n    listen 80;\n    underscores_in_headers on;\n    set $appflowy_cloud_backend \"http://127.0.0.1:8000\";\n    set $gotrue_backend \"http://127.0.0.1:9999\";\n    set $admin_frontend_backend \"http://127.0.0.1:3001\";\n    set $appflowy_web_backend \"http://127.0.0.1:3000\";\n    set $minio_backend \"http://127.0.0.1:9001\";\n    set $minio_api_backend \"http://127.0.0.1:9000\";\n    # Host name for minio, used internally within docker compose\n    set $minio_internal_host \"minio:9000\";\n\n    # GoTrue\n    location /gotrue/ {\n        proxy_pass $gotrue_backend;\n\n        rewrite ^/gotrue(/.*)$ $1 break;\n\n        # Allow headers like redirect_to to be handed over to the gotrue\n        # for correct redirecting\n        proxy_set_header Host $http_host;\n        proxy_pass_request_headers on;\n    }\n\n    # WebSocket\n    location /ws {\n        proxy_pass $appflowy_cloud_backend;\n\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"Upgrade\";\n        proxy_set_header Host $host;\n        proxy_read_timeout 86400s;\n    }\n\n    location /api {\n        proxy_pass $appflowy_cloud_backend;\n        proxy_set_header X-Request-Id $request_id;\n        proxy_set_header Host $http_host;\n\n        location ~* ^/api/workspace/([a-zA-Z0-9_-]+)/publish$ {\n            proxy_pass $appflowy_cloud_backend;\n            proxy_request_buffering off;\n            client_max_body_size 256M;\n        }\n\n        # AppFlowy-Cloud\n        location /api/chat {\n            proxy_pass $appflowy_cloud_backend;\n\n            proxy_http_version 1.1;\n            proxy_set_header Connection \"\";\n            chunked_transfer_encoding on;\n            proxy_buffering off;\n            proxy_cache off;\n\n            proxy_read_timeout 600s;\n            proxy_connect_timeout 600s;\n            proxy_send_timeout 600s;\n        }\n\n        location /api/import {\n            proxy_pass $appflowy_cloud_backend;\n\n            # Set headers\n            proxy_set_header X-Request-Id $request_id;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Host $scheme://$host;\n\n            # Timeouts\n            proxy_read_timeout 600s;\n            proxy_connect_timeout 600s;\n            proxy_send_timeout 600s;\n\n            # Disable buffering for large file uploads\n            proxy_request_buffering off;\n            proxy_buffering off;\n            proxy_cache off;\n            client_max_body_size 2G;\n        }\n    }\n\n    # Minio Web UI\n    # Derive from: https://min.io/docs/minio/linux/integrations/setup-nginx-proxy-with-minio.html\n    # Optional Module, comment this section if you are did not deploy minio in docker-compose.yml\n    # This endpoint is meant to be used for the MinIO Web UI, accessible via the admin portal\n    location /minio/ {\n        proxy_pass $minio_backend;\n\n        rewrite ^/minio/(.*) /$1 break;\n        proxy_set_header Host $http_host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-NginX-Proxy true;\n\n        ## This is necessary to pass the correct IP to be hashed\n        real_ip_header X-Real-IP;\n\n        proxy_connect_timeout 300s;\n\n        ## To support websockets in MinIO versions released after January 2023\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        # Some environments may encounter CORS errors (Kubernetes + Nginx Ingress)\n        # Uncomment the following line to set the Origin request to an empty string\n        # proxy_set_header Origin '';\n\n        chunked_transfer_encoding off;\n    }\n\n    # Optional Module, comment this section if you are did not deploy minio in docker-compose.yml\n    # This is used for presigned url, which is needs to be exposed to the AppFlowy client application.\n    location /minio-api/ {\n        proxy_pass $minio_api_backend;\n\n        # Set the host to internal host because the presigned url was signed against the internal host\n        proxy_set_header Host $minio_internal_host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n\n        rewrite ^/minio-api/(.*) /$1 break;\n\n        proxy_connect_timeout 300s;\n        # Default is HTTP/1, keepalive is only enabled in HTTP/1.1\n        proxy_http_version 1.1;\n        proxy_set_header Connection \"\";\n        chunked_transfer_encoding off;\n        client_max_body_size 1000M;\n    }\n\n    # Admin Frontend\n    # Optional Module, comment this section if you are did not deploy admin_frontend in docker-compose.yml\n    location /console {\n        proxy_pass $admin_frontend_backend;\n\n        proxy_set_header X-Scheme $scheme;\n        proxy_set_header Host $host;\n    }\n\n    # AppFlowy Web\n    location / {\n        proxy_pass $appflowy_web_backend;\n        proxy_set_header X-Scheme $scheme;\n        proxy_set_header Host $host;\n    }\n}\n"
  },
  {
    "path": "libs/access-control/Cargo.toml",
    "content": "[package]\nname = \"access-control\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nactix-http.workspace = true\napp-error.workspace = true\nanyhow.workspace = true\nasync-trait.workspace = true\ncasbin = { version = \"2.10.1\", features = [\n  \"cached\",\n  \"runtime-tokio\",\n  \"incremental\",\n], optional = true }\ndatabase.workspace = true\ndatabase-entity.workspace = true\nfutures-util.workspace = true\nlazy_static.workspace = true\nprometheus-client.workspace = true\nrand = \"0.8\"\nredis = { workspace = true, features = [\n  \"aio\",\n  \"tokio-comp\",\n  \"connection-manager\",\n] }\nserde_json = { version = \"1.0\" }\nsqlx = { workspace = true, default-features = false, features = [\"postgres\"] }\ntracing.workspace = true\ntokio = { workspace = true, features = [\"macros\", \"time\", \"rt-multi-thread\"] }\ntokio-stream.workspace = true\nuuid = { workspace = true, features = [\"v4\"] }\nserde = { version = \"1.0.200\", features = [\"derive\"] }\ninfra.workspace = true\n\n[features]\ndefault = [\"casbin\"]\ncasbin = [\"dep:casbin\"]\n"
  },
  {
    "path": "libs/access-control/src/act.rs",
    "content": "use actix_http::Method;\nuse database_entity::dto::{AFAccessLevel, AFRole};\nuse std::cmp::Ordering;\n\n/// Defines behavior for objects that can translate to a set of action identifiers.\n///\npub trait Acts {\n  fn policy_acts(&self) -> Vec<String> {\n    vec![self.to_enforce_act()]\n  }\n  fn to_enforce_act(&self) -> String;\n  fn from_enforce_act(act: &str) -> Self;\n}\n\nimpl Acts for AFAccessLevel {\n  /// maps different access levels to a set of predefined action\n  /// identifiers that represent permissions. It starts with a base action applicable\n  /// to all access levels and extends this set based on the access level:\n  ///\n  /// - `ReadOnly`: Only includes the base action (`\"10\"`), indicating minimum permissions.\n  /// - `ReadAndComment`: Adds `\"20\"` to the base action, allowing reading and commenting.\n  /// - `ReadAndWrite`: Extends permissions to include `\"20\"` and `\"30\"`, enabling reading, commenting, and writing.\n  /// - `FullAccess`: Grants all possible actions by including `\"20\"`, `\"30\"`, and `\"50\"`, representing the highest level of access.\n  ///\n  fn to_enforce_act(&self) -> String {\n    match self {\n      AFAccessLevel::ReadOnly => \"l:10\".to_string(),\n      AFAccessLevel::ReadAndComment => \"l:20\".to_string(),\n      AFAccessLevel::ReadAndWrite => \"l:30\".to_string(),\n      AFAccessLevel::FullAccess => \"l:50\".to_string(),\n    }\n  }\n\n  fn from_enforce_act(act: &str) -> Self {\n    match act {\n      \"l:10\" => AFAccessLevel::ReadOnly,\n      \"l:20\" => AFAccessLevel::ReadAndComment,\n      \"l:30\" => AFAccessLevel::ReadAndWrite,\n      \"l:50\" => AFAccessLevel::FullAccess,\n      _ => AFAccessLevel::ReadOnly,\n    }\n  }\n}\n\nimpl Acts for AFRole {\n  /// Returns a vector of action identifiers associated with a user's role.\n  ///\n  /// The function maps each role to a set of actions, reflecting a permission hierarchy\n  /// where higher roles inherit the permissions of the lower ones. The roles are defined\n  /// as follows:\n  /// - `Owner`: Has the highest level of access, including all possible actions (`\"1\"`, `\"2\"`, and `\"3\"`).\n  /// - `Member`: Can perform a subset of actions allowed for owners, excluding the most privileged ones (`\"2\"` and `\"3\"`).\n  /// - `Guest`: Has the least level of access, limited to the least privileged actions (`\"3\"` only).\n  ///\n  fn to_enforce_act(&self) -> String {\n    match self {\n      AFRole::Owner => \"r:1\".to_string(),\n      AFRole::Member => \"r:2\".to_string(),\n      AFRole::Guest => \"r:3\".to_string(),\n    }\n  }\n\n  fn from_enforce_act(act: &str) -> Self {\n    match act {\n      \"r:1\" => AFRole::Owner,\n      \"r:2\" => AFRole::Member,\n      \"r:3\" => AFRole::Guest,\n      _ => AFRole::Guest,\n    }\n  }\n}\n\n/// Represents the actions that can be performed on objects.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub enum Action {\n  Read,\n  Write,\n  Delete,\n}\n\nimpl PartialOrd for Action {\n  fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n    Some(self.cmp(other))\n  }\n}\n\nimpl Ord for Action {\n  fn cmp(&self, other: &Self) -> Ordering {\n    match (self, other) {\n      // Read\n      (Action::Read, Action::Read) => Ordering::Equal,\n      (Action::Read, _) => Ordering::Less,\n      (_, Action::Read) => Ordering::Greater,\n      // Write\n      (Action::Write, Action::Write) => Ordering::Equal,\n      (Action::Write, Action::Delete) => Ordering::Less,\n      // Delete\n      (Action::Delete, Action::Write) => Ordering::Greater,\n      (Action::Delete, Action::Delete) => Ordering::Equal,\n    }\n  }\n}\n\nimpl Acts for Action {\n  fn to_enforce_act(&self) -> String {\n    match self {\n      Action::Read => \"read\".to_string(),\n      Action::Write => \"write\".to_string(),\n      Action::Delete => \"delete\".to_string(),\n    }\n  }\n\n  fn from_enforce_act(act: &str) -> Self {\n    match act {\n      \"read\" => Action::Read,\n      \"write\" => Action::Write,\n      \"delete\" => Action::Delete,\n      _ => Action::Read,\n    }\n  }\n}\n\nimpl From<&Method> for Action {\n  fn from(method: &Method) -> Self {\n    match *method {\n      Method::POST => Action::Write,\n      Method::PUT => Action::Write,\n      Method::DELETE => Action::Delete,\n      _ => Action::Read,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/access.rs",
    "content": "use super::adapter::PgAdapter;\nuse crate::act::{Action, Acts};\nuse crate::entity::{ObjectType, SubjectType};\nuse crate::metrics::{tick_metric, AccessControlMetrics};\n\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse casbin::function_map::OperatorFunction;\nuse casbin::rhai::{Dynamic, ImmutableString};\nuse casbin::{CachedEnforcer, CoreApi, DefaultModel, MgmtApi};\nuse database_entity::dto::{AFAccessLevel, AFRole};\n\nuse sqlx::PgPool;\n\nuse crate::casbin::enforcer_v2::{AFEnforcerV2, ConsistencyMode};\nuse std::sync::Arc;\nuse tracing::trace;\n\n/// Manages access control.\n///\n/// Stores access control policies in the form `subject, object, role`\n/// where `subject` is `uid`, `object` is `oid`, and `role` is [AFAccessLevel] or [AFRole].\n///\n/// Roles are mapped to the corresponding actions that they are allowed to perform.\n/// `FullAccess` has write\n/// `FullAccess` has read\n///\n/// Access control requests are made in the form `subject, object, action`\n/// and will be evaluated against the policies and mappings stored,\n/// according to the model defined.\n#[derive(Clone)]\npub struct AccessControl {\n  enforcer: Arc<AFEnforcerV2>,\n  #[allow(dead_code)]\n  access_control_metrics: Arc<AccessControlMetrics>,\n}\n\nimpl AccessControl {\n  pub async fn new(\n    pg_pool: PgPool,\n    redis_uri: Option<&str>,\n    access_control_metrics: Arc<AccessControlMetrics>,\n  ) -> Result<Self, AppError> {\n    let model = casbin_model().await?;\n    let adapter = PgAdapter::new(pg_pool.clone(), access_control_metrics.clone());\n    let mut enforcer = casbin::CachedEnforcer::new(model, adapter)\n      .await\n      .map_err(|e| {\n        AppError::Internal(anyhow!(\"Failed to create access control enforcer: {}\", e))\n      })?;\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n\n    let enforcer = match redis_uri {\n      None => AFEnforcerV2::new(enforcer).await?,\n      Some(redis_uri) => AFEnforcerV2::new_with_redis(enforcer, redis_uri).await?,\n    };\n\n    tick_metric(\n      enforcer.metrics_state.clone(),\n      access_control_metrics.clone(),\n    );\n    Ok(Self {\n      enforcer: Arc::new(enforcer),\n      access_control_metrics,\n    })\n  }\n\n  #[cfg(test)]\n  pub fn with_enforcer(enforcer: AFEnforcerV2) -> Self {\n    let access_control_metrics = Arc::new(AccessControlMetrics::init());\n    Self {\n      enforcer: Arc::new(enforcer),\n      access_control_metrics,\n    }\n  }\n\n  pub async fn update_policy<T>(\n    &self,\n    sub: SubjectType,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<(), AppError>\n  where\n    T: Acts,\n  {\n    self.enforcer.update_policy(sub, obj, act).await?;\n    Ok(())\n  }\n\n  pub async fn remove_policy(&self, sub: SubjectType, obj: ObjectType) -> Result<(), AppError> {\n    self.enforcer.remove_policy(sub, obj).await?;\n    Ok(())\n  }\n\n  /// Enforces access control policy with eventual consistency.\n  ///\n  /// This method provides fast policy checks by evaluating against the current state\n  /// without waiting for pending policy updates to be applied. Use this when:\n  /// - Performance is critical\n  /// - Slight inconsistency is acceptable\n  /// - You need immediate responses\n  pub async fn enforce_immediately<T>(\n    &self,\n    uid: &i64,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self.enforcer.enforce_policy(uid, obj, act).await\n  }\n\n  /// Enforces access control policy with strong consistency.\n  ///\n  /// This method ensures all pending policy updates are applied before evaluation,\n  /// guaranteeing the most up-to-date permissions check. Use this when:\n  /// - Consistency is critical (e.g., security-sensitive operations)\n  /// - After policy changes that must be immediately reflected\n  /// - You can afford to wait for pending updates\n  pub async fn enforce_strong<T>(\n    &self,\n    uid: &i64,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self\n      .enforcer\n      .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Strong)\n      .await\n  }\n\n  pub async fn enforce_weak<T>(&self, uid: &i64, obj: ObjectType, act: T) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self\n      .enforcer\n      .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Eventual)\n      .await\n  }\n}\n\n///\n/// ## Policy Definitions:\n/// - p1 = sub=uid, obj=object_id, act=role_id\n///   - Associates a user (`uid`) with a role (`role_id`) for accessing an object (`object_id`).\n///\n/// - p2 = sub=uid, obj=object_id, act=access_level\n///   - Specifies the access level (`access_level`) a user (`uid`) has for an object (`object_id`).\n///\n/// - p3 = sub=guid, obj=object_id, act=access_level\n///   - Defines the access level (`access_level`) a group (`guid`) has for an object (`object_id`).\n///\n/// ## Role Definitions in Database:\n/// Roles and access levels are defined with the following mappings:\n/// - **Role \"1\" (Owner):** Can `delete`, `write`, and `read`.\n/// - **Role \"2\" (Member):** Can `write` and `read`.\n/// - **Role \"3\" (Guest):** Can `write` and `read`.\n///\n/// ## Access Levels:\n/// - **\"10\" (Read-only):** Permission to `read`.\n/// - **\"20\" (Read and Comment):** Permission to `read`.\n/// - **\"30\" (Read and Write):** Permissions to `read` and `write`.\n/// - **\"50\" (Full Access):** Permissions to `read`, `write`, and `delete`.\n///\n/// ## Matchers:\n/// - `m = r.sub == p.sub && p.obj == r.obj && g(p.act, r.act)`\n///   Evaluates whether the subject and object in the request match those in a policy and if the\n///   given role or access level authorizes the action.\n///\n/// ## Examples:\n/// ### Policy 1 Evaluation (User Access with Role):\n/// ```text\n/// Request: api/workspace/123, uid=1, workspace_id=123, method=GET\n/// - `r = sub = 1, obj = 123, act = read`\n/// - `p = sub = 1, obj = 123, act = 1` (Policy in DB)\n/// Evaluation:\n/// - Subject Match: `r.sub == p.sub`\n/// - Object Match: `p.obj == r.obj`\n/// - Action Permission: `g(p.act, r.act) => g(1, read) => [\"1\", \"read\"]`\n/// Result: Allow\n/// ```\n///\n/// ### Policy 3 Evaluation (Group Access with Access Level):\n/// ```text\n/// Request: api/collab/123, uid=1, object_id=123, guid=g1, method=GET\n/// - `r = sub = g1, obj = 123, act = read`\n/// - `p = sub = g1, obj = 123, act = 50` (Policy in DB)\n/// Evaluation:\n/// - Subject Match: `r.sub == p.sub`\n/// - Object Match: `p.obj == r.obj`\n/// - Enforce by Access Level: `g(p.act, r.act) => g(50, read) => [\"50\", \"read\"]`\n/// Result: Allow\n/// ```\n///\n/// casbin model online writer: https://casbin.org/editor/\npub const MODEL_CONF: &str = r###\"\n[request_definition]\nr = sub, obj, act\n\n[policy_definition]\np = sub, obj, act\n\n[role_definition]\ng = _, _ # grouping rule\n\n[policy_effect]\ne = some(where (p.eft == allow))\n\n[matchers]\nm = r.sub == p.sub && p.obj == r.obj && (g(p.act, r.act) || cmpRoleOrLevel(r.act, p.act))\n\"###;\n\npub async fn casbin_model() -> Result<DefaultModel, AppError> {\n  let model = casbin::DefaultModel::from_str(MODEL_CONF)\n    .await\n    .map_err(|e| AppError::Internal(anyhow!(\"Failed to create access control model: {}\", e)))?;\n  Ok(model)\n}\n\n/// Compares role or access level between a request and a policy.\n///\n/// it is designed to compare roles or access levels specified in the request and policy.\n/// It supports two prefixes: \"r:\" for roles and \"l:\" for access levels. When the prefixes match,\n/// it compares the values to determine if the policy's role or level is greater than or equal to\n/// the request's role or level.\n///\n/// # Arguments\n/// * `r_act` - The role or access level from the request, prefixed with \"r:\" for roles or \"l:\" for levels.\n/// * `p_act` - The role or access level from the policy, prefixed with \"r:\" for roles or \"l:\" for levels.\n///\npub fn cmp_role_or_level(r_act: ImmutableString, p_act: ImmutableString) -> Dynamic {\n  trace!(\"cmp_role_or_level: r: {} p: {}\", r_act, p_act);\n\n  if r_act.starts_with(\"r:\") && p_act.starts_with(\"r:\") {\n    let r = AFRole::from_enforce_act(r_act.as_str());\n    let p = AFRole::from_enforce_act(p_act.as_str());\n    return Dynamic::from_bool(p >= r);\n  }\n\n  if r_act.starts_with(\"l:\") && p_act.starts_with(\"l:\") {\n    let r = AFAccessLevel::from_enforce_act(r_act.as_str());\n    let p = AFAccessLevel::from_enforce_act(p_act.as_str());\n    return Dynamic::from_bool(p >= r);\n  }\n\n  if r_act.starts_with(\"l:\") && p_act.starts_with(\"r:\") {\n    let r = AFAccessLevel::from_enforce_act(r_act.as_str());\n    let role = AFRole::from_enforce_act(p_act.as_str());\n    let p = AFAccessLevel::from(&role);\n    return Dynamic::from_bool(p >= r);\n  }\n\n  Dynamic::from_bool(false)\n}\n\n/// Represents the entity stored at the index of the access control policy.\n/// `subject_id, object_id, role/action`\n///\n/// E.g. user1, collab::123, Owner\n///\npub const POLICY_FIELD_INDEX_SUBJECT: usize = 0;\npub const POLICY_FIELD_INDEX_OBJECT: usize = 1;\npub const POLICY_FIELD_INDEX_ACTION: usize = 2;\n\n/// Represents the entity stored at the index of the grouping.\n/// `role, action`\n///\n/// E.g. Owner, Write\n#[allow(dead_code)]\nconst GROUPING_FIELD_INDEX_ROLE: usize = 0;\n#[allow(dead_code)]\nconst GROUPING_FIELD_INDEX_ACTION: usize = 1;\n\npub(crate) async fn load_group_policies(enforcer: &mut CachedEnforcer) -> Result<(), AppError> {\n  // Grouping definition of access level to action.\n  let af_access_levels = [\n    AFAccessLevel::ReadOnly,\n    AFAccessLevel::ReadAndComment,\n    AFAccessLevel::ReadAndWrite,\n    AFAccessLevel::FullAccess,\n  ];\n  let mut grouping_policies = Vec::new();\n  for level in &af_access_levels {\n    // All levels can read\n    grouping_policies.push([level.to_enforce_act(), Action::Read.to_enforce_act()].to_vec());\n    if level.can_write() {\n      grouping_policies.push([level.to_enforce_act(), Action::Write.to_enforce_act()].to_vec());\n    }\n    if level.can_delete() {\n      grouping_policies.push([level.to_enforce_act(), Action::Delete.to_enforce_act()].to_vec());\n    }\n  }\n\n  let af_roles = [AFRole::Owner, AFRole::Member, AFRole::Guest];\n  for role in &af_roles {\n    match role {\n      AFRole::Owner => {\n        grouping_policies.push([role.to_enforce_act(), Action::Delete.to_enforce_act()].to_vec());\n        grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec());\n        grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec());\n      },\n      AFRole::Member => {\n        grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec());\n        grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec());\n      },\n      AFRole::Guest => {\n        grouping_policies.push([role.to_enforce_act(), Action::Write.to_enforce_act()].to_vec());\n        grouping_policies.push([role.to_enforce_act(), Action::Read.to_enforce_act()].to_vec());\n      },\n    }\n  }\n\n  enforcer\n    .add_grouping_policies(grouping_policies)\n    .await\n    .map_err(|e| AppError::Internal(anyhow!(\"Failed to add grouping policies: {}\", e)))?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/adapter.rs",
    "content": "use async_trait::async_trait;\n\nuse crate::entity::ObjectType;\nuse crate::metrics::AccessControlMetrics;\nuse casbin::Adapter;\nuse casbin::Filter;\nuse casbin::Model;\nuse casbin::Result;\n\nuse database::pg_row::AFWorkspaceMemberPermRow;\nuse database::workspace::select_workspace_member_perm_stream;\n\nuse crate::act::Acts;\nuse futures_util::stream::BoxStream;\nuse sqlx::PgPool;\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tokio_stream::StreamExt;\n\n/// Implementation of [`casbin::Adapter`] for access control authorisation.\n/// Access control policies that are managed by workspace and collab CRUD.\npub struct PgAdapter {\n  pg_pool: PgPool,\n  access_control_metrics: Arc<AccessControlMetrics>,\n}\n\nimpl PgAdapter {\n  pub fn new(pg_pool: PgPool, access_control_metrics: Arc<AccessControlMetrics>) -> Self {\n    Self {\n      pg_pool,\n      access_control_metrics,\n    }\n  }\n}\n\n/// Loads workspace policies from a given stream of workspace member permissions.\n///\n/// This function iterates over the stream of member permissions, constructing and accumulating\n/// policies for each member. A policy is represented as a vector of strings containing the user ID,\n/// object type (workspace), and action (derived from their role within the workspace). Additional\n/// policies are added for roles with implicit permissions (e.g., owners implicitly have member and\n/// guest permissions).\n///\n/// # Arguments\n///\n/// * `stream` - A stream of `sqlx::Result<AFWorkspaceMemberPermRow>` representing the database\n///   query results for workspace member permissions.\n///\n/// # Returns\n///\n/// Returns a `Result<Vec<Vec<String>>>`, which is a vector of policies. Each policy is itself a\n/// vector containing the user ID, policy object, and action as strings. In case of an error while\n/// processing the stream, returns the error encapsulated within `Result`.\n///\n/// # Example Policy Vector\n///\n/// For a workspace owner with user ID `1` and workspace ID `123`, the function generates policies\n/// such as:\n///\n/// ```ignore\n/// [\n///   [\"1\", \"workspace:123\", \"owner\"],\n///   [\"1\", \"workspace:123\", \"member\"], // Implicit permission for owner\n///   [\"1\", \"workspace:123\", \"guest\"],  // Implicit permission for owner\n/// ]\n/// ```\n///\n/// # Note\n///\n/// - The function handles additional policies for `Owner` and `Member` roles to include implicit\n///   permissions. For example, an `Owner` implicitly has `Member` and `Guest` permissions, and a\n///   `Member` implicitly has `Guest` permissions.\n/// - The policy object is derived from the `ObjectType::Workspace`, and actions are derived from\n///   member roles (`Owner`, `Member`, `Guest`) using the `to_action` method.\npub async fn load_workspace_policies(\n  mut stream: BoxStream<'_, sqlx::Result<AFWorkspaceMemberPermRow>>,\n) -> Result<Vec<Vec<String>>> {\n  let mut policies: Vec<Vec<String>> = Vec::new();\n\n  while let Some(Ok(member_permission)) = stream.next().await {\n    let uid = member_permission.uid;\n    let workspace_id = member_permission.workspace_id.to_string();\n    let object_type = ObjectType::Workspace(workspace_id.to_string());\n    for act in member_permission.role.policy_acts() {\n      let policy = vec![\n        uid.to_string(),\n        object_type.policy_object(),\n        act.to_string(),\n      ];\n      policies.push(policy);\n    }\n  }\n\n  Ok(policies)\n}\n\n#[async_trait]\nimpl Adapter for PgAdapter {\n  async fn load_policy(&mut self, model: &mut dyn Model) -> Result<()> {\n    let start = Instant::now();\n    let workspace_member_perm_stream = select_workspace_member_perm_stream(&self.pg_pool);\n    let workspace_policies = load_workspace_policies(workspace_member_perm_stream).await?;\n\n    // Policy definition `p` of type `p`. See `model.conf`\n    model.add_policies(\"p\", \"p\", workspace_policies);\n\n    self\n      .access_control_metrics\n      .record_load_all_policies_in_ms(start.elapsed().as_millis() as u64);\n\n    Ok(())\n  }\n\n  async fn load_filtered_policy<'a>(&mut self, m: &mut dyn Model, _f: Filter<'a>) -> Result<()> {\n    // No support for filtered.\n    self.load_policy(m).await\n  }\n  async fn save_policy(&mut self, _m: &mut dyn Model) -> Result<()> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(())\n  }\n  async fn clear_policy(&mut self) -> Result<()> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(())\n  }\n  fn is_filtered(&self) -> bool {\n    // No support for filtered.\n    false\n  }\n  async fn add_policy(&mut self, _sec: &str, _ptype: &str, _rule: Vec<String>) -> Result<bool> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(true)\n  }\n  async fn add_policies(\n    &mut self,\n    _sec: &str,\n    _ptype: &str,\n    _rules: Vec<Vec<String>>,\n  ) -> Result<bool> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(true)\n  }\n  async fn remove_policy(&mut self, _sec: &str, _ptype: &str, _rule: Vec<String>) -> Result<bool> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(true)\n  }\n  async fn remove_policies(\n    &mut self,\n    _sec: &str,\n    _ptype: &str,\n    _rules: Vec<Vec<String>>,\n  ) -> Result<bool> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(true)\n  }\n  async fn remove_filtered_policy(\n    &mut self,\n    _sec: &str,\n    _ptype: &str,\n    _field_index: usize,\n    _field_values: Vec<String>,\n  ) -> Result<bool> {\n    // unimplemented!()\n    //\n    // Adapter is used only for loading policies from database\n    // since policies are managed by workspace and collab CRUD.\n    Ok(true)\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/collab.rs",
    "content": "use crate::{\n  act::Action,\n  collab::{CollabAccessControl, RealtimeAccessControl},\n  entity::ObjectType,\n};\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse database_entity::dto::AFAccessLevel;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::access::AccessControl;\n\n#[derive(Clone)]\npub struct CollabAccessControlImpl {\n  access_control: AccessControl,\n}\n\nimpl CollabAccessControlImpl {\n  pub fn new(access_control: AccessControl) -> Self {\n    Self { access_control }\n  }\n}\n\n#[async_trait]\nimpl CollabAccessControl for CollabAccessControlImpl {\n  async fn enforce_action(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    _oid: &Uuid,\n    action: Action,\n  ) -> Result<(), AppError> {\n    // TODO: allow non workspace member to read a collab.\n\n    // Anyone who can write to a workspace, can also delete a collab.\n    let workspace_action = match action {\n      Action::Read => Action::Read,\n      Action::Write => Action::Write,\n      Action::Delete => Action::Write,\n    };\n\n    let result = self\n      .access_control\n      .enforce_immediately(\n        uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        workspace_action,\n      )\n      .await;\n    match result {\n      Ok(true) => Ok(()),\n      Ok(false) => Err(AppError::NotEnoughPermissions),\n      Err(e) => Err(e),\n    }\n  }\n\n  async fn enforce_access_level(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    _oid: &Uuid,\n    access_level: AFAccessLevel,\n  ) -> Result<(), AppError> {\n    // TODO: allow non workspace member to read a collab.\n\n    // Anyone who can write to a workspace, also have full access to a collab.\n    let workspace_action = match access_level {\n      AFAccessLevel::ReadOnly => Action::Read,\n      AFAccessLevel::ReadAndComment => Action::Read,\n      AFAccessLevel::ReadAndWrite => Action::Write,\n      AFAccessLevel::FullAccess => Action::Write,\n    };\n\n    let result = self\n      .access_control\n      .enforce_immediately(\n        uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        workspace_action,\n      )\n      .await;\n    match result {\n      Ok(true) => Ok(()),\n      Ok(false) => Err(AppError::NotEnoughPermissions),\n      Err(e) => Err(e),\n    }\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  async fn update_access_level_policy(\n    &self,\n    _uid: &i64,\n    _oid: &Uuid,\n    _level: AFAccessLevel,\n  ) -> Result<(), AppError> {\n    // TODO: allow non workspace member to read a collab.\n    Ok(())\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  async fn remove_access_level(&self, _uid: &i64, _oid: &Uuid) -> Result<(), AppError> {\n    // TODO: allow non workspace member to read a collab.\n    Ok(())\n  }\n}\n\n#[derive(Clone)]\npub struct RealtimeCollabAccessControlImpl {\n  access_control: AccessControl,\n}\n\nimpl RealtimeCollabAccessControlImpl {\n  pub fn new(access_control: AccessControl) -> Self {\n    Self { access_control }\n  }\n\n  async fn can_perform_action(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    _oid: &Uuid,\n    required_action: Action,\n  ) -> Result<bool, AppError> {\n    // TODO: allow non workspace member to read a collab.\n\n    // Anyone who can write to a workspace, can also delete a collab.\n    let workspace_action = match required_action {\n      Action::Read => Action::Read,\n      Action::Write => Action::Write,\n      Action::Delete => Action::Write,\n    };\n\n    self\n      .access_control\n      .enforce_immediately(\n        uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        workspace_action,\n      )\n      .await\n  }\n}\n\n#[async_trait]\nimpl RealtimeAccessControl for RealtimeCollabAccessControlImpl {\n  async fn can_write_collab(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n  ) -> Result<bool, AppError> {\n    self\n      .can_perform_action(workspace_id, uid, oid, Action::Write)\n      .await\n  }\n\n  async fn can_read_collab(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n  ) -> Result<bool, AppError> {\n    self\n      .can_perform_action(workspace_id, uid, oid, Action::Read)\n      .await\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use database_entity::dto::AFRole;\n  use uuid::Uuid;\n\n  use crate::casbin::util::tests::test_enforcer_v2;\n  use crate::{\n    act::Action,\n    casbin::access::AccessControl,\n    collab::CollabAccessControl,\n    entity::{ObjectType, SubjectType},\n  };\n\n  #[tokio::test]\n  pub async fn test_collab_access_control() {\n    let enforcer = test_enforcer_v2().await;\n    let uid = 1;\n    let workspace_id = Uuid::new_v4();\n    let oid = Uuid::new_v4();\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Member,\n      )\n      .await\n      .unwrap();\n    let access_control = AccessControl::with_enforcer(enforcer);\n    let collab_access_control = super::CollabAccessControlImpl::new(access_control);\n    for action in [Action::Read, Action::Write, Action::Delete] {\n      collab_access_control\n        .enforce_action(&workspace_id, &uid, &oid, action.clone())\n        .await\n        .unwrap_or_else(|_| panic!(\"Failed to enforce action: {:?}\", action));\n    }\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/enforcer.rs",
    "content": "use super::access::load_group_policies;\nuse crate::act::Acts;\nuse crate::casbin::util::policies_for_subject_with_given_object;\nuse crate::entity::{ObjectType, SubjectType};\nuse crate::metrics::MetricsCalState;\nuse crate::request::PolicyRequest;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse casbin::{CachedEnforcer, CoreApi, MgmtApi};\nuse rand::Rng;\nuse std::sync::atomic::Ordering;\nuse std::time::{Duration, Instant};\nuse tokio::sync::RwLock;\nuse tokio::time::sleep;\nuse tracing::{event, instrument, trace, warn};\n\n/// Configuration for retry logic with exponential backoff\n#[derive(Clone, Debug)]\npub(crate) struct RetryConfig {\n  /// Base delay for exponential backoff (before jitter)\n  pub base_delay: Duration,\n  /// Maximum delay between retries (cap for exponential backoff)\n  pub max_delay: Duration,\n  /// Maximum number of retry attempts\n  pub max_retries: usize,\n  /// Total timeout for all retry attempts\n  pub timeout: Duration,\n  /// Initial random delay range to prevent immediate thundering herd\n  pub initial_jitter_max: Duration,\n}\n\nimpl Default for RetryConfig {\n  fn default() -> Self {\n    Self {\n      base_delay: Duration::from_millis(100),\n      max_delay: Duration::from_millis(1000),\n      max_retries: 50,\n      timeout: Duration::from_secs(5),\n      initial_jitter_max: Duration::from_millis(50),\n    }\n  }\n}\n\n#[cfg(test)]\npub struct AFEnforcer {\n  enforcer: RwLock<CachedEnforcer>,\n  pub(crate) metrics_state: MetricsCalState,\n}\n\n#[cfg(test)]\nimpl AFEnforcer {\n  pub async fn new(mut enforcer: CachedEnforcer) -> Result<Self, AppError> {\n    load_group_policies(&mut enforcer).await?;\n    Ok(Self {\n      enforcer: RwLock::new(enforcer),\n      metrics_state: MetricsCalState::new(),\n    })\n  }\n\n  /// Retry acquiring a write lock with default configuration\n  async fn retry_write(\n    &self,\n  ) -> Result<tokio::sync::RwLockWriteGuard<'_, CachedEnforcer>, AppError> {\n    self.retry_write_with_config(RetryConfig::default()).await\n  }\n\n  /// Calculate next delay using decorrelated jitter strategy\n  /// Decorrelated jitter: delay = random(base_delay, last_delay * 3)\n  pub(crate) fn calculate_next_delay(last_delay: &mut Duration, config: &RetryConfig) -> Duration {\n    let mut rng = rand::thread_rng();\n    let min_delay = config.base_delay.as_millis() as u64;\n    let max_delay = std::cmp::min(\n      config.max_delay.as_millis() as u64,\n      last_delay.saturating_mul(3).as_millis() as u64,\n    );\n    let jitter_ms = rng.gen_range(min_delay..=max_delay.max(min_delay));\n    let new_delay = Duration::from_millis(jitter_ms);\n    *last_delay = new_delay;\n    new_delay\n  }\n\n  /// Generate initial random delay to prevent immediate thundering herd\n  pub(crate) fn generate_initial_delay(max_delay: Duration) -> Duration {\n    if max_delay == Duration::ZERO {\n      return Duration::ZERO;\n    }\n    let mut rng = rand::thread_rng();\n    let delay_ms = rng.gen_range(0..=max_delay.as_millis().max(1) as u64);\n    Duration::from_millis(delay_ms)\n  }\n\n  /// Retry acquiring a write lock with improved concurrent handling\n  /// Uses advanced jitter strategies to prevent thundering herd\n  #[instrument(level = \"debug\", skip_all)]\n  pub(crate) async fn retry_write_with_config(\n    &self,\n    config: RetryConfig,\n  ) -> Result<tokio::sync::RwLockWriteGuard<'_, CachedEnforcer>, AppError> {\n    let start_time = Instant::now();\n\n    // Add initial random delay to prevent immediate thundering herd\n    if config.initial_jitter_max > Duration::ZERO {\n      let initial_delay = Self::generate_initial_delay(config.initial_jitter_max);\n      sleep(initial_delay).await;\n    }\n\n    let mut last_delay = config.base_delay;\n    let mut attempt = 0;\n\n    loop {\n      // Primary constraint: Check timeout first\n      let elapsed = start_time.elapsed();\n      if elapsed >= config.timeout {\n        warn!(\n          \"Timeout while acquiring write lock after {} attempts in {:?}\",\n          attempt, elapsed\n        );\n        return Err(AppError::RetryLater(anyhow!(\n          \"Timeout while acquiring write lock after {} attempts in {:?}\",\n          attempt,\n          elapsed\n        )));\n      }\n\n      match self.enforcer.try_write() {\n        Ok(guard) => {\n          if attempt > 0 {\n            trace!(\n              \"Successfully acquired write lock after {} attempts in {:?}\",\n              attempt + 1,\n              elapsed\n            );\n          }\n          return Ok(guard);\n        },\n        Err(_) => {\n          attempt += 1;\n\n          // Calculate next delay\n          let delay = Self::calculate_next_delay(&mut last_delay, &config);\n\n          // Check if delay would exceed timeout (primary constraint)\n          if start_time.elapsed() + delay >= config.timeout {\n            warn!(\n              \"Next retry delay ({:?}) would exceed timeout, stopping after {} attempts in {:?}\",\n              delay,\n              attempt,\n              start_time.elapsed()\n            );\n            return Err(AppError::RetryLater(anyhow!(\n              \"Would exceed timeout with next retry delay after {} attempts in {:?}\",\n              attempt,\n              start_time.elapsed()\n            )));\n          }\n\n          // Secondary constraint: Safety limit to prevent infinite retries (only if we have a bug)\n          if attempt >= config.max_retries {\n            warn!(\n              \"🚨 Reached maximum retry safety limit ({}) - this should rarely happen! Elapsed: {:?}\",\n              config.max_retries,\n              start_time.elapsed()\n            );\n            return Err(AppError::RetryLater(anyhow!(\n              \"Reached maximum retry safety limit ({}) after {:?}\",\n              config.max_retries,\n              start_time.elapsed()\n            )));\n          }\n\n          trace!(\n            \"Failed to acquire write lock on attempt {}, retrying after {:?} (elapsed: {:?})\",\n            attempt,\n            delay,\n            start_time.elapsed()\n          );\n\n          sleep(delay).await;\n        },\n      }\n    }\n  }\n\n  /// Update policy for a user.\n  /// If the policy is already exist, then it will return Ok(false).\n  ///\n  /// [`ObjectType::Workspace`] has to be paired with [`ActionType::Role`],\n  /// [`ObjectType::Collab`] has to be paired with [`ActionType::Level`],\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn update_policy<T>(\n    &self,\n    sub: SubjectType,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<(), AppError>\n  where\n    T: Acts,\n  {\n    let policies = act\n      .policy_acts()\n      .into_iter()\n      .map(|act| vec![sub.policy_subject(), obj.policy_object(), act])\n      .collect::<Vec<Vec<_>>>();\n\n    trace!(\"[access control]: add policy:{:?}\", policies);\n\n    // DEADLOCK PREVENTION:\n    // We use retry_write() instead of self.enforcer.write().await to prevent deadlocks.\n    //\n    // Problem with write().await:\n    // 1. write().await can block indefinitely waiting for the lock\n    // 2. If the lock is held while calling .await on add_policies(), the task yields to the runtime\n    // 3. Other tasks on the same thread may then try to acquire the same write lock\n    // 4. If casbin internally uses synchronous locks that depend on this operation completing,\n    //    we get a circular dependency: Task A holds async lock → waits for sync lock →\n    //    Task B holds sync lock → waits for async lock → DEADLOCK\n    let mut enforcer = self.retry_write().await?;\n\n    enforcer\n      .add_policies(policies)\n      .await\n      .map_err(|e| AppError::Internal(anyhow!(\"fail to add policy: {e:?}\")))?;\n\n    Ok(())\n  }\n\n  /// Returns policies that match the filter.\n  #[allow(dead_code)]\n  pub async fn remove_policy(\n    &self,\n    sub: SubjectType,\n    object_type: ObjectType,\n  ) -> Result<(), AppError> {\n    let policies_for_user_on_object = {\n      let enforcer = self.enforcer.read().await;\n      policies_for_subject_with_given_object(sub.clone(), object_type.clone(), &enforcer).await\n    };\n\n    event!(\n      tracing::Level::INFO,\n      \"[access control]: remove policy:subject={}, object={}, policies={:?}\",\n      sub.policy_subject(),\n      object_type.policy_object(),\n      policies_for_user_on_object\n    );\n\n    // DEADLOCK PREVENTION:\n    // We use retry_write() instead of self.enforcer.write().await to prevent deadlocks.\n    //\n    // Problem with write().await:\n    // 1. write().await can block indefinitely waiting for the lock\n    // 2. If the lock is held while calling .await on add_policies(), the task yields to the runtime\n    // 3. Other tasks on the same thread may then try to acquire the same write lock\n    // 4. If casbin internally uses synchronous locks that depend on this operation completing,\n    //    we get a circular dependency: Task A holds async lock → waits for sync lock →\n    //    Task B holds sync lock → waits for async lock → DEADLOCK\n    let mut enforcer = self.retry_write().await?;\n    enforcer\n      .remove_policies(policies_for_user_on_object)\n      .await\n      .map_err(|e| AppError::Internal(anyhow!(\"error enforce: {e:?}\")))?;\n\n    Ok(())\n  }\n\n  /// ## Parameters:\n  /// - `uid`: The user ID of the user attempting the action.\n  /// - `obj`: The type of object being accessed, encapsulated within an `ObjectType`.\n  /// - `act`: The action being attempted, encapsulated within an `ActionVariant`.\n  ///\n  /// ## Returns:\n  /// - `Ok(true)`: If the user is authorized to perform the action based on any of the evaluated policies.\n  /// - `Ok(false)`: If none of the policies authorize the user to perform the action.\n  /// - `Err(AppError)`: If an error occurs during policy enforcement.\n  ///\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn enforce_policy<T>(\n    &self,\n    uid: &i64,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self\n      .metrics_state\n      .total_read_enforce_result\n      .fetch_add(1, Ordering::Relaxed);\n\n    let policy_request = PolicyRequest::new(*uid, obj, act);\n    let policy = policy_request.to_policy();\n    let result = self\n      .enforcer\n      .read()\n      .await\n      .enforce(policy)\n      .map_err(|e| AppError::Internal(anyhow!(\"enforce: {e:?}\")))?;\n    Ok(result)\n  }\n}\n\n#[cfg(test)]\npub(crate) mod tests {\n  use crate::{\n    act::Action,\n    casbin::access::{casbin_model, cmp_role_or_level},\n    entity::{ObjectType, SubjectType},\n  };\n  use casbin::{function_map::OperatorFunction, prelude::*};\n  use database_entity::dto::{AFAccessLevel, AFRole};\n  use std::collections::HashMap;\n  use std::sync::Arc;\n  use std::time::{Duration, Instant};\n  use tokio::sync::Barrier;\n\n  use super::{AFEnforcer, RetryConfig};\n\n  pub async fn test_enforcer() -> AFEnforcer {\n    let model = casbin_model().await.unwrap();\n    let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default())\n      .await\n      .unwrap();\n\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n    AFEnforcer::new(enforcer).await.unwrap()\n  }\n\n  #[tokio::test]\n  async fn test_retry_config_defaults() {\n    let config = RetryConfig::default();\n    assert_eq!(config.base_delay, Duration::from_millis(100));\n    assert_eq!(config.max_delay, Duration::from_millis(1000));\n    assert_eq!(config.max_retries, 50);\n    assert_eq!(config.timeout, Duration::from_secs(5));\n    assert_eq!(config.initial_jitter_max, Duration::from_millis(50));\n  }\n\n  #[tokio::test]\n  async fn test_decorrelated_jitter_delay_calculation() {\n    let config = RetryConfig::default();\n    let mut last_delay = config.base_delay;\n\n    // Test multiple delay calculations to ensure they're within expected bounds\n    for _ in 0..10 {\n      let delay = AFEnforcer::calculate_next_delay(&mut last_delay, &config);\n\n      // Delay should be between base_delay and max_delay\n      assert!(delay >= config.base_delay);\n      assert!(delay <= config.max_delay);\n\n      // Delay should be at least base_delay\n      assert!(delay.as_millis() >= config.base_delay.as_millis());\n    }\n  }\n\n  #[tokio::test]\n  async fn test_decorrelated_jitter_progression() {\n    let config = RetryConfig {\n      base_delay: Duration::from_millis(10),\n      max_delay: Duration::from_millis(500),\n      max_retries: 5,\n      timeout: Duration::from_secs(10),\n      initial_jitter_max: Duration::ZERO, // Disable initial jitter for predictable testing\n    };\n\n    let mut last_delay = config.base_delay;\n    let mut delays = Vec::new();\n\n    // Generate a sequence of delays\n    for _ in 0..5 {\n      let delay = AFEnforcer::calculate_next_delay(&mut last_delay, &config);\n      delays.push(delay);\n    }\n\n    // Verify delays are within bounds and show variation\n    for delay in &delays {\n      assert!(delay >= &config.base_delay);\n      assert!(delay <= &config.max_delay);\n    }\n\n    // Verify we have some variation (not all delays are the same)\n    let all_same = delays.windows(2).all(|w| w[0] == w[1]);\n    assert!(!all_same, \"Delays should show variation due to jitter\");\n  }\n\n  #[tokio::test]\n  async fn test_initial_jitter_generation() {\n    let max_delay = Duration::from_millis(100);\n\n    // Generate multiple initial delays to test variation\n    let mut delays = Vec::new();\n    for _ in 0..10 {\n      let delay = AFEnforcer::generate_initial_delay(max_delay);\n      delays.push(delay);\n      assert!(delay <= max_delay);\n    }\n\n    // Test zero max delay\n    let zero_delay = AFEnforcer::generate_initial_delay(Duration::ZERO);\n    assert_eq!(zero_delay, Duration::ZERO);\n\n    // Verify we have some variation\n    let all_same = delays.windows(2).all(|w| w[0] == w[1]);\n    assert!(!all_same, \"Initial delays should show variation\");\n  }\n\n  #[tokio::test]\n  async fn test_retry_write_success_on_first_attempt() {\n    let enforcer = test_enforcer().await;\n    let start = Instant::now();\n\n    // Should succeed immediately since no contention\n    let _guard = enforcer.retry_write().await.unwrap();\n    let elapsed = start.elapsed();\n\n    // Should complete quickly (within 100ms)\n    assert!(elapsed < Duration::from_millis(100));\n  }\n\n  #[tokio::test]\n  async fn test_retry_write_with_custom_config() {\n    let enforcer = test_enforcer().await;\n\n    let config = RetryConfig {\n      base_delay: Duration::from_millis(5),\n      max_delay: Duration::from_millis(50),\n      max_retries: 3,\n      timeout: Duration::from_millis(200),\n      initial_jitter_max: Duration::from_millis(10),\n    };\n\n    let start = Instant::now();\n    let _guard = enforcer.retry_write_with_config(config).await.unwrap();\n    let elapsed = start.elapsed();\n\n    // Should complete quickly since no contention, but may have initial jitter\n    assert!(elapsed < Duration::from_millis(100));\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_concurrent_retry_behavior() {\n    let enforcer = Arc::new(test_enforcer().await);\n    let barrier = Arc::new(Barrier::new(5));\n    let mut handles = Vec::new();\n\n    // Spawn 5 concurrent tasks that will try to get write locks\n    for i in 0..5 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        // Wait for all tasks to be ready\n        barrier_clone.wait().await;\n\n        let start = Instant::now();\n        let result = enforcer_clone.retry_write().await;\n        let elapsed = start.elapsed();\n\n        (i, result.is_ok(), elapsed)\n      });\n\n      handles.push(handle);\n    }\n\n    // Wait for all tasks to complete\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // All should succeed eventually\n    let successful_count = results.iter().filter(|(_, success, _)| *success).count();\n    assert_eq!(\n      successful_count, 5,\n      \"All concurrent requests should eventually succeed\"\n    );\n\n    // Some should take longer than others due to retries and jitter\n    let times: Vec<Duration> = results.iter().map(|(_, _, elapsed)| *elapsed).collect();\n    let min_time = times.iter().min().unwrap();\n    let max_time = times.iter().max().unwrap();\n\n    // There should be some spread in completion times due to jitter\n    println!(\n      \"Concurrent retry times: min={:?}, max={:?}\",\n      min_time, max_time\n    );\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_retry_timeout() {\n    let enforcer = Arc::new(test_enforcer().await);\n\n    // Hold a write lock to force retries\n    let _blocking_guard = enforcer.retry_write().await.unwrap();\n\n    let config = RetryConfig {\n      base_delay: Duration::from_millis(10),\n      max_delay: Duration::from_millis(50),\n      max_retries: 10,\n      timeout: Duration::from_millis(100), // Short timeout\n      initial_jitter_max: Duration::ZERO,\n    };\n\n    let start = Instant::now();\n    let result = enforcer.retry_write_with_config(config).await;\n    let elapsed = start.elapsed();\n\n    // Should timeout and return error\n    assert!(result.is_err());\n\n    // The retry logic may exit early when it determines the next delay would exceed timeout\n    // This is good optimization behavior, so we don't enforce a minimum time\n    // Just verify it doesn't take unreasonably long\n    assert!(\n      elapsed < Duration::from_millis(200),\n      \"Should not take too long even when timing out: {:?}\",\n      elapsed\n    );\n\n    // Verify the error message indicates a timeout/retry issue\n    if let Err(app_error) = result {\n      let error_msg = format!(\"{}\", app_error);\n      assert!(\n        error_msg.contains(\"timeout\")\n          || error_msg.contains(\"retry\")\n          || error_msg.contains(\"Timeout\")\n          || error_msg.contains(\"RetryLater\"),\n        \"Error should indicate timeout or retry issue: {}\",\n        error_msg\n      );\n    }\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_high_concurrency_jitter_effectiveness() {\n    let enforcer = Arc::new(test_enforcer().await);\n    let barrier = Arc::new(Barrier::new(10));\n    let mut handles = Vec::new();\n\n    // Spawn 10 concurrent tasks to test jitter effectiveness\n    for i in 0..10 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let config = RetryConfig {\n          base_delay: Duration::from_millis(5),\n          max_delay: Duration::from_millis(100),\n          max_retries: 5,\n          timeout: Duration::from_secs(2),\n          initial_jitter_max: Duration::from_millis(20),\n        };\n\n        let start = Instant::now();\n        let result = enforcer_clone.retry_write_with_config(config).await;\n        let elapsed = start.elapsed();\n\n        (i, result.is_ok(), elapsed)\n      });\n\n      handles.push(handle);\n    }\n\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // All should succeed\n    let successful_count = results.iter().filter(|(_, success, _)| *success).count();\n    assert_eq!(\n      successful_count, 10,\n      \"All high-concurrency requests should succeed\"\n    );\n\n    // Completion times should be well distributed due to jitter\n    let times: Vec<Duration> = results.iter().map(|(_, _, elapsed)| *elapsed).collect();\n    let mut times_ms: Vec<u128> = times.iter().map(|d| d.as_millis()).collect();\n    times_ms.sort();\n\n    println!(\"High concurrency completion times (ms): {:?}\", times_ms);\n\n    // Check that times are reasonably spread out (not all clustered)\n    let first_quartile = times_ms[2]; // 3rd fastest\n    let third_quartile = times_ms[7]; // 8th fastest\n    let spread = third_quartile.saturating_sub(first_quartile);\n\n    // There should be meaningful spread between completion times\n    // Lower threshold since jitter is working so well that contention is minimal\n    assert!(\n      spread > 2,\n      \"Completion times should show good spread due to jitter, got spread: {}ms\",\n      spread\n    );\n  }\n\n  #[tokio::test]\n  async fn policy_comparison_test() {\n    let enforcer = test_enforcer().await;\n    let uid = 1;\n    let workspace_id = \"w1\";\n\n    // add user as a member of the workspace\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Member,\n      )\n      .await\n      .expect(\"update policy failed\");\n\n    // test if enforce can compare requested action and the role policy\n    for action in [Action::Write, Action::Read] {\n      let result = enforcer\n        .enforce_policy(\n          &uid,\n          ObjectType::Workspace(workspace_id.to_string()),\n          action.clone(),\n        )\n        .await\n        .unwrap_or_else(|_| panic!(\"enforcing action={:?} failed\", action));\n      assert!(result, \"action={:?} should be allowed\", action);\n    }\n    let result = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        Action::Delete,\n      )\n      .await\n      .expect(\"enforcing action=Delete failed\");\n    assert!(!result, \"action=Delete should not be allowed\");\n\n    let result = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Member,\n      )\n      .await\n      .expect(\"enforcing role=Member failed\");\n    assert!(result, \"role=Member should be allowed\");\n\n    let result = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .expect(\"enforcing role=Owner failed\");\n    assert!(!result, \"role=Owner should not be allowed\");\n\n    for access_level in [\n      AFAccessLevel::ReadOnly,\n      AFAccessLevel::ReadAndComment,\n      AFAccessLevel::ReadAndWrite,\n    ] {\n      let result = enforcer\n        .enforce_policy(\n          &uid,\n          ObjectType::Workspace(workspace_id.to_string()),\n          access_level,\n        )\n        .await\n        .unwrap_or_else(|_| panic!(\"enforcing access_level={:?} failed\", access_level));\n      assert!(result, \"access_level={:?} should be allowed\", access_level);\n    }\n    let result = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFAccessLevel::FullAccess,\n      )\n      .await\n      .expect(\"enforcing access_level=FullAccess failed\");\n    assert!(!result, \"access_level=FullAccess should not be allowed\")\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_concurrent_update_policy_operations() {\n    // Test with 100 concurrent operations to really stress the retry logic\n    test_concurrent_update_policy_operations_with_count(100).await;\n  }\n\n  // Helper function to test concurrent update operations with configurable count\n  async fn test_concurrent_update_policy_operations_with_count(concurrent_count: usize) {\n    let enforcer = Arc::new(test_enforcer().await);\n    let barrier = Arc::new(Barrier::new(concurrent_count));\n    let mut handles = Vec::new();\n\n    println!(\n      \"Testing {} concurrent update_policy operations\",\n      concurrent_count\n    );\n\n    // Spawn concurrent tasks for update_policy operations\n    for i in 0..concurrent_count {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let user_id = 1000 + i as i64; // Different users\n        let workspace_id = format!(\"workspace_{}\", i % 5); // 5 different workspaces for more contention\n        let role = if i % 3 == 0 {\n          AFRole::Owner\n        } else if i % 3 == 1 {\n          AFRole::Member\n        } else {\n          AFRole::Guest\n        };\n\n        let start = Instant::now();\n        let result = enforcer_clone\n          .update_policy(\n            SubjectType::User(user_id),\n            ObjectType::Workspace(workspace_id),\n            role,\n          )\n          .await;\n        let elapsed = start.elapsed();\n\n        (i, result.is_ok(), elapsed)\n      });\n\n      handles.push(handle);\n    }\n\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // All policy updates should succeed\n    let successful_count = results.iter().filter(|(_, success, _)| *success).count();\n    assert_eq!(\n      successful_count, concurrent_count,\n      \"All {} concurrent update_policy operations should succeed\",\n      concurrent_count\n    );\n\n    // Analyze timing distribution to show jitter effectiveness\n    let times: Vec<Duration> = results.iter().map(|(_, _, elapsed)| *elapsed).collect();\n    let mut times_ms: Vec<u128> = times.iter().map(|d| d.as_millis()).collect();\n    times_ms.sort();\n\n    println!(\n      \"Concurrent update_policy completion times (first 10): {:?}\",\n      &times_ms[..10.min(times_ms.len())]\n    );\n\n    if times_ms.len() > 10 {\n      println!(\n        \"Concurrent update_policy completion times (last 10): {:?}\",\n        &times_ms[times_ms.len() - 10..]\n      );\n    }\n\n    // Verify reasonable spread in completion times due to jitter\n    let min_time = times_ms[0];\n    let max_time = times_ms[concurrent_count - 1];\n    let spread = max_time.saturating_sub(min_time);\n    let median_time = times_ms[concurrent_count / 2];\n    let percentile_95 = times_ms[(concurrent_count * 95) / 100];\n\n    println!(\n      \"Update policy stats: min={}ms, median={}ms, 95th={}ms, max={}ms, spread={}ms\",\n      min_time, median_time, percentile_95, max_time, spread\n    );\n\n    // With higher concurrency, we expect significant timing spread due to jitter\n    let expected_min_spread = if concurrent_count >= 50 { 20 } else { 5 };\n    assert!(\n      spread > expected_min_spread,\n      \"Should show timing spread due to concurrent write lock contention: {}ms (expected > {}ms)\",\n      spread,\n      expected_min_spread\n    );\n\n    // Verify that operations complete in reasonable time even under high load\n    assert!(\n      max_time < 5000, // 5 seconds max\n      \"Even under high concurrency, operations should complete within 5 seconds: {}ms\",\n      max_time\n    );\n\n    // Check distribution - no more than 20% should complete at the same time\n    let timing_distribution = times_ms.iter().fold(HashMap::new(), |mut acc, &time| {\n      *acc.entry(time).or_insert(0) += 1;\n      acc\n    });\n\n    let max_same_timing = timing_distribution.values().max().unwrap_or(&0);\n    let max_allowed_clustering = (times_ms.len() * 20) / 100; // 20% threshold\n\n    assert!(\n      *max_same_timing <= max_allowed_clustering,\n      \"Too many operations completed at the same time: {} out of {} (jitter should distribute better)\",\n      max_same_timing,\n      times_ms.len()\n    );\n\n    println!(\n      \"Successfully completed {} concurrent update operations with excellent distribution!\",\n      concurrent_count\n    );\n  }\n\n  // Additional test with different concurrency levels\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_scalable_concurrent_update_policy_operations() {\n    // Test different scales to verify jitter effectiveness across various loads\n    for &count in &[10, 25, 50] {\n      println!(\"\\n=== Testing {} concurrent operations ===\", count);\n      test_concurrent_update_policy_operations_with_count(count).await;\n    }\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_concurrent_enforce_policy_operations() {\n    let enforcer = Arc::new(test_enforcer().await);\n\n    // First, set up some policies for testing\n    for i in 0..3 {\n      let user_id = 2000 + i;\n      let workspace_id = format!(\"test_workspace_{}\", i);\n\n      enforcer\n        .update_policy(\n          SubjectType::User(user_id),\n          ObjectType::Workspace(workspace_id),\n          AFRole::Member,\n        )\n        .await\n        .expect(\"Failed to set up test policy\");\n    }\n\n    let barrier = Arc::new(Barrier::new(15));\n    let mut handles = Vec::new();\n\n    // Simulate 15 concurrent enforce_policy operations\n    for i in 0..15 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let user_id = 2000 + (i % 3); // Use the users we set up\n        let workspace_id = format!(\"test_workspace_{}\", i % 3);\n        let action = if i % 3 == 0 {\n          Action::Read\n        } else if i % 3 == 1 {\n          Action::Write\n        } else {\n          Action::Delete\n        };\n\n        let start = Instant::now();\n        let result = enforcer_clone\n          .enforce_policy(\n            &user_id,\n            ObjectType::Workspace(workspace_id),\n            action.clone(),\n          )\n          .await;\n        let elapsed = start.elapsed();\n\n        (i, result.is_ok(), elapsed, result.unwrap_or(false))\n      });\n\n      handles.push(handle);\n    }\n\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // All enforce operations should succeed (though some may be denied by policy)\n    let successful_count = results.iter().filter(|(_, success, _, _)| *success).count();\n    assert_eq!(\n      successful_count, 15,\n      \"All concurrent enforce_policy operations should succeed\"\n    );\n\n    // Count how many were actually allowed by policy\n    let allowed_count = results.iter().filter(|(_, _, _, allowed)| *allowed).count();\n    println!(\n      \"Policy enforcement results: {} out of {} actions were allowed\",\n      allowed_count,\n      results.len()\n    );\n\n    // Analyze timing distribution\n    let times: Vec<Duration> = results.iter().map(|(_, _, elapsed, _)| *elapsed).collect();\n    let mut times_ms: Vec<u128> = times.iter().map(|d| d.as_millis()).collect();\n    times_ms.sort();\n\n    println!(\n      \"Concurrent enforce_policy completion times (ms): {:?}\",\n      times_ms\n    );\n\n    // Even read operations can benefit from jitter if there's write contention\n    let max_time = times_ms[14];\n    println!(\"Max enforce_policy time: {}ms\", max_time);\n\n    // All should complete reasonably quickly since these are mostly read operations\n    assert!(\n      max_time < 100,\n      \"Enforce operations should complete quickly: {}ms\",\n      max_time\n    );\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_mixed_concurrent_operations_realistic_scenario() {\n    let enforcer = Arc::new(test_enforcer().await);\n    let barrier = Arc::new(Barrier::new(20));\n    let mut handles = Vec::new();\n\n    // Simulate realistic mixed workload: 20 operations total\n    // 30% update_policy (write operations)\n    // 70% enforce_policy (read operations)\n    for i in 0..20 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let start = Instant::now();\n\n        // 30% are update operations, 70% are enforce operations\n        let operation_result = if i < 6 {\n          // Update policy operations (write-heavy)\n          let user_id = 3000 + i;\n          let workspace_id = format!(\"mixed_workspace_{}\", i % 2);\n          let role = AFRole::Member;\n\n          let result = enforcer_clone\n            .update_policy(\n              SubjectType::User(user_id),\n              ObjectType::Workspace(workspace_id),\n              role,\n            )\n            .await;\n\n          (\"update\", result.is_ok(), result.is_ok())\n        } else {\n          // Enforce policy operations (read-heavy)\n          let user_id = 3000 + (i % 6); // Refer to users created by update operations\n          let workspace_id = format!(\"mixed_workspace_{}\", i % 2);\n          let action = Action::Read;\n\n          let result = enforcer_clone\n            .enforce_policy(&user_id, ObjectType::Workspace(workspace_id), action)\n            .await;\n\n          match result {\n            Ok(allowed) => (\"enforce\", true, allowed),\n            Err(_) => (\"enforce\", false, false),\n          }\n        };\n\n        let elapsed = start.elapsed();\n        (\n          i,\n          operation_result.0,\n          operation_result.1,\n          operation_result.2,\n          elapsed,\n        )\n      });\n\n      handles.push(handle);\n    }\n\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // Analyze results by operation type\n    let update_results: Vec<_> = results\n      .iter()\n      .filter(|(_, op_type, _, _, _)| *op_type == \"update\")\n      .collect();\n    let enforce_results: Vec<_> = results\n      .iter()\n      .filter(|(_, op_type, _, _, _)| *op_type == \"enforce\")\n      .collect();\n\n    println!(\"Mixed workload results:\");\n    println!(\"- Update operations: {} total\", update_results.len());\n    println!(\"- Enforce operations: {} total\", enforce_results.len());\n\n    // All operations should succeed\n    let all_successful = results.iter().all(|(_, _, success, _, _)| *success);\n    assert!(\n      all_successful,\n      \"All mixed concurrent operations should succeed\"\n    );\n\n    // Analyze timing patterns\n    let update_times: Vec<u128> = update_results\n      .iter()\n      .map(|(_, _, _, _, elapsed)| elapsed.as_millis())\n      .collect();\n    let enforce_times: Vec<u128> = enforce_results\n      .iter()\n      .map(|(_, _, _, _, elapsed)| elapsed.as_millis())\n      .collect();\n\n    if !update_times.is_empty() {\n      let avg_update_time = update_times.iter().sum::<u128>() / update_times.len() as u128;\n      println!(\"- Average update_policy time: {}ms\", avg_update_time);\n    }\n\n    if !enforce_times.is_empty() {\n      let avg_enforce_time = enforce_times.iter().sum::<u128>() / enforce_times.len() as u128;\n      println!(\"- Average enforce_policy time: {}ms\", avg_enforce_time);\n    }\n\n    // All times collected for overall analysis\n    let all_times: Vec<u128> = results\n      .iter()\n      .map(|(_, _, _, _, elapsed)| elapsed.as_millis())\n      .collect();\n    let mut sorted_times = all_times.clone();\n    sorted_times.sort();\n\n    println!(\n      \"- Mixed operation completion times (ms): {:?}\",\n      sorted_times\n    );\n\n    let min_time = sorted_times[0];\n    let max_time = sorted_times[19];\n    let spread = max_time.saturating_sub(min_time);\n\n    println!(\n      \"- Timing spread: {}ms (min: {}ms, max: {}ms)\",\n      spread, min_time, max_time\n    );\n\n    // The jitter should help distribute the load even in mixed scenarios\n    assert!(\n      spread > 3,\n      \"Mixed workload should show timing distribution due to jitter: {}ms\",\n      spread\n    );\n\n    // For mixed workload, fast read operations (0ms) are expected and excellent\n    // Only check write operation distribution to avoid penalizing good read performance\n    let write_times: Vec<u128> = update_results\n      .iter()\n      .map(|(_, _, _, _, elapsed)| elapsed.as_millis())\n      .collect();\n\n    if write_times.len() > 1 {\n      let write_distribution = write_times.iter().fold(HashMap::new(), |mut acc, &time| {\n        *acc.entry(time).or_insert(0) += 1;\n        acc\n      });\n\n      let max_same_write_timing = write_distribution.values().max().unwrap_or(&0);\n      let max_allowed_write_clustering = (write_times.len() * 60) / 100; // 60% threshold for write operations\n\n      assert!(*max_same_write_timing <= max_allowed_write_clustering,\n              \"Too many write operations completed at the same time: {} out of {} (jitter should distribute writes better)\",\n              max_same_write_timing, write_times.len());\n    }\n\n    println!(\n      \"Mixed workload distribution is excellent - fast reads (0ms) and well-distributed writes\"\n    );\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn test_high_contention_write_operations() {\n    let enforcer = Arc::new(test_enforcer().await);\n    let barrier = Arc::new(Barrier::new(12));\n    let mut handles = Vec::new();\n\n    // Simulate high write contention: multiple updates to the same workspace\n    let shared_workspace = \"high_contention_workspace\".to_string();\n\n    for i in 0..12 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n      let workspace_id = shared_workspace.clone();\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let user_id = 4000 + i;\n        let role = if i % 3 == 0 {\n          AFRole::Owner\n        } else if i % 3 == 1 {\n          AFRole::Member\n        } else {\n          AFRole::Guest\n        };\n\n        let start = Instant::now();\n        let result = enforcer_clone\n          .update_policy(\n            SubjectType::User(user_id),\n            ObjectType::Workspace(workspace_id),\n            role,\n          )\n          .await;\n        let elapsed = start.elapsed();\n\n        (i, result.is_ok(), elapsed)\n      });\n\n      handles.push(handle);\n    }\n\n    let mut results = Vec::new();\n    for handle in handles {\n      results.push(handle.await.unwrap());\n    }\n\n    // All high-contention writes should succeed\n    let successful_count = results.iter().filter(|(_, success, _)| *success).count();\n    assert_eq!(\n      successful_count, 12,\n      \"All high-contention write operations should succeed\"\n    );\n\n    // Analyze the retry effectiveness under high write contention\n    let times: Vec<Duration> = results.iter().map(|(_, _, elapsed)| *elapsed).collect();\n    let mut times_ms: Vec<u128> = times.iter().map(|d| d.as_millis()).collect();\n    times_ms.sort();\n\n    println!(\n      \"High contention write operations completion times (ms): {:?}\",\n      times_ms\n    );\n\n    let min_time = times_ms[0];\n    let max_time = times_ms[11];\n    let spread = max_time.saturating_sub(min_time);\n    let median_time = times_ms[6];\n\n    println!(\n      \"High contention stats: min={}ms, median={}ms, max={}ms, spread={}ms\",\n      min_time, median_time, max_time, spread\n    );\n\n    // Under high contention, we expect significant timing spread due to retries and jitter\n    assert!(\n      spread > 10,\n      \"High write contention should show significant timing spread: {}ms\",\n      spread\n    );\n\n    // Some operations should take longer due to retries\n    assert!(\n      max_time > min_time * 2,\n      \"Some operations should take significantly longer due to retries\"\n    );\n\n    // But all should still complete in reasonable time\n    assert!(\n      max_time < 1000,\n      \"Even under high contention, operations should complete within 1 second: {}ms\",\n      max_time\n    );\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/enforcer_v2.rs",
    "content": "use super::access::load_group_policies;\nuse crate::act::Acts;\nuse crate::casbin::util::policies_for_subject_with_given_object;\nuse crate::entity::{ObjectType, SubjectType};\nuse crate::metrics::MetricsCalState;\nuse crate::request::PolicyRequest;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse casbin::{CachedApi, CachedEnforcer, CoreApi, MgmtApi};\nuse std::collections::HashSet;\nuse std::sync::atomic::{AtomicU64, Ordering};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::{mpsc, Notify, RwLock};\nuse tokio::time::timeout;\nuse tracing::{error, event, info, instrument, trace, warn};\n\n/// Consistency mode for policy enforcement\n#[derive(Debug, Clone, Default, Copy)]\npub enum ConsistencyMode {\n  /// Default mode - returns immediately with potentially stale data\n  #[default]\n  Eventual,\n  /// Waits for all pending updates to complete before enforcing\n  Strong,\n  /// Waits for specific pending updates affecting the requested subject/object\n  BoundedStrong { timeout_ms: u64 },\n}\n\n/// Commands for policy updates\n#[derive(Debug)]\nenum PolicyCommand {\n  AddPolicies {\n    policies: Vec<Vec<String>>,\n    generation: u64,\n    subject_object_keys: Vec<(String, String)>, // (subject, object) pairs\n    response: tokio::sync::oneshot::Sender<Result<(), AppError>>,\n  },\n  RemovePolicies {\n    policies: Vec<Vec<String>>,\n    generation: u64,\n    subject_object_keys: Vec<(String, String)>, // (subject, object) pairs\n    response: tokio::sync::oneshot::Sender<Result<(), AppError>>,\n  },\n  Shutdown,\n}\n\npub struct AFEnforcerV2 {\n  enforcer: Arc<RwLock<CachedEnforcer>>,\n  pub(crate) metrics_state: MetricsCalState,\n  policy_cmd_tx: mpsc::Sender<PolicyCommand>,\n  /// Tracks the current generation of policy updates\n  generation: Arc<AtomicU64>,\n  /// Tracks the last processed generation\n  processed_generation: Arc<AtomicU64>,\n  /// Tracks pending operations by subject-object key\n  pending_operations: Arc<RwLock<HashSet<(String, String)>>>,\n  /// Notifies when a generation has been processed\n  generation_notify: Arc<Notify>,\n}\n\nimpl AFEnforcerV2 {\n  pub async fn new(enforcer: CachedEnforcer) -> Result<Self, AppError> {\n    Self::new_internal(enforcer).await\n  }\n\n  pub async fn new_with_redis(\n    mut enforcer: CachedEnforcer,\n    redis_uri: &str,\n  ) -> Result<Self, AppError> {\n    use super::redis_cache::RedisCache;\n    match RedisCache::new(redis_uri) {\n      Ok(redis_cache) => {\n        info!(\"[access control v2]: Using Redis cache at {}\", redis_uri);\n        enforcer.set_cache(Box::new(redis_cache));\n        Self::new_internal(enforcer).await\n      },\n      Err(e) => {\n        warn!(\n          \"[access control v2]: Failed to connect to Redis cache: {}. Using in-memory cache.\",\n          e\n        );\n        Self::new_internal(enforcer).await\n      },\n    }\n  }\n\n  async fn new_internal(mut enforcer: CachedEnforcer) -> Result<Self, AppError> {\n    load_group_policies(&mut enforcer).await?;\n\n    // Create command channel with bounded capacity\n    // Read capacity from environment variable, defaulting to 2000 if not set or invalid\n    let channel_capacity = std::env::var(\"ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY\")\n      .ok()\n      .and_then(|s| s.parse::<usize>().ok())\n      .unwrap_or(2000);\n\n    trace!(\n      \"[access control v2]: Policy channel capacity set to {}\",\n      channel_capacity\n    );\n    let (tx, rx) = mpsc::channel::<PolicyCommand>(channel_capacity);\n    let enforcer = Arc::new(RwLock::new(enforcer));\n\n    // Create consistency tracking\n    let generation = Arc::new(AtomicU64::new(0));\n    let processed_generation = Arc::new(AtomicU64::new(0));\n    let pending_operations = Arc::new(RwLock::new(HashSet::new()));\n    let generation_notify = Arc::new(Notify::new());\n\n    // Spawn processor with consistency tracking\n    tokio::spawn(Self::policy_update_processor(\n      rx,\n      enforcer.clone(),\n      processed_generation.clone(),\n      pending_operations.clone(),\n      generation_notify.clone(),\n    ));\n\n    Ok(Self {\n      enforcer,\n      metrics_state: MetricsCalState::new(),\n      policy_cmd_tx: tx,\n      generation,\n      processed_generation,\n      pending_operations,\n      generation_notify,\n    })\n  }\n\n  /// Background task that processes policy updates sequentially\n  async fn policy_update_processor(\n    mut rx: mpsc::Receiver<PolicyCommand>,\n    enforcer: Arc<RwLock<CachedEnforcer>>,\n    processed_generation: Arc<AtomicU64>,\n    pending_operations: Arc<RwLock<HashSet<(String, String)>>>,\n    generation_notify: Arc<Notify>,\n  ) {\n    info!(\"[access control v2]: Policy update processor started\");\n    let buffer_size = 2;\n    let mut buf = Vec::with_capacity(buffer_size);\n\n    loop {\n      trace!(\"[access control v2]: Waiting for policy commands...\");\n      let n = rx.recv_many(&mut buf, buffer_size).await;\n      if n == 0 {\n        info!(\"[access control v2]: Channel closed, exiting processor\");\n        break;\n      }\n\n      info!(\"[access control v2]: Received {} policy commands\", n);\n      let mut enforcer = enforcer.write().await;\n      let mut max_generation = 0u64;\n      let mut processed_keys = Vec::new();\n      for cmd in buf.drain(..) {\n        match cmd {\n          PolicyCommand::AddPolicies {\n            policies,\n            generation,\n            subject_object_keys,\n            response,\n          } => {\n            max_generation = max_generation.max(generation);\n            processed_keys.extend(subject_object_keys);\n            let result = async {\n              enforcer\n                .add_policies(policies)\n                .await\n                .map_err(|e| AppError::Internal(anyhow!(\"fail to add policy: {e:?}\")))?;\n              Ok(())\n            }\n            .await;\n            trace!(\"[access control v2]: AddPolicies result: {:?}\", result);\n            let _ = response.send(result);\n          },\n          PolicyCommand::RemovePolicies {\n            policies,\n            generation,\n            subject_object_keys,\n            response,\n          } => {\n            max_generation = max_generation.max(generation);\n            processed_keys.extend(subject_object_keys);\n            let result = async {\n              enforcer\n                .remove_policies(policies)\n                .await\n                .map_err(|e| AppError::Internal(anyhow!(\"fail to remove policy: {e:?}\")))?;\n              Ok(())\n            }\n            .await;\n            trace!(\"[access control v2]: RemovePolicies result: {:?}\", result);\n            let _ = response.send(result);\n          },\n          PolicyCommand::Shutdown => {\n            trace!(\"[access control v2]: Policy update processor shutting down\");\n            return;\n          },\n        }\n      }\n      drop(enforcer);\n      trace!(\"[access control v2]: Finished processing {} commands\", n);\n      // Update consistency tracking\n      if max_generation > 0 {\n        trace!(\n          \"[access control v2]: Updating processed generation from {} to {}\",\n          processed_generation.load(Ordering::SeqCst),\n          max_generation\n        );\n        processed_generation.store(max_generation, Ordering::SeqCst);\n        if !processed_keys.is_empty() {\n          let mut pending = pending_operations.write().await;\n          for key in processed_keys {\n            pending.remove(&key);\n          }\n        }\n\n        // Notify waiters\n        trace!(\n          \"[access control v2]: Notifying waiters after processing generation {}\",\n          max_generation\n        );\n        generation_notify.notify_waiters();\n      }\n    }\n  }\n\n  /// Send a command with metrics tracking\n  async fn send_command_with_metrics(&self, cmd: PolicyCommand) -> Result<(), AppError> {\n    // Increment send attempts\n    self\n      .metrics_state\n      .policy_send_attempts\n      .fetch_add(1, Ordering::Relaxed);\n\n    // First try to send without blocking to detect if channel is full\n    match self.policy_cmd_tx.try_send(cmd) {\n      Ok(()) => {\n        trace!(\"[access control v2]: Command sent successfully\");\n        Ok(())\n      },\n      Err(mpsc::error::TrySendError::Full(cmd)) => {\n        self\n          .metrics_state\n          .policy_channel_full_events\n          .fetch_add(1, Ordering::Relaxed);\n\n        warn!(\"[access control v2]: Policy channel is full, waiting to send...\");\n        let send_timeout = Duration::from_secs(5);\n        match timeout(send_timeout, self.policy_cmd_tx.send(cmd)).await {\n          Ok(Ok(())) => {\n            trace!(\"[access control v2]: Command sent successfully after waiting\");\n            Ok(())\n          },\n          Ok(Err(_)) => {\n            self\n              .metrics_state\n              .policy_send_failures\n              .fetch_add(1, Ordering::Relaxed);\n            Err(AppError::Internal(anyhow!(\"Policy update channel closed\")))\n          },\n          Err(_) => {\n            self\n              .metrics_state\n              .policy_send_failures\n              .fetch_add(1, Ordering::Relaxed);\n            Err(AppError::Internal(anyhow!(\n              \"Policy update timed out after {} seconds - channel may be overloaded\",\n              send_timeout.as_secs()\n            )))\n          },\n        }\n      },\n      Err(mpsc::error::TrySendError::Closed(_)) => {\n        self\n          .metrics_state\n          .policy_send_failures\n          .fetch_add(1, Ordering::Relaxed);\n        Err(AppError::Internal(anyhow!(\"Policy update channel closed\")))\n      },\n    }\n  }\n\n  /// Update policy for a user using queue-based approach.\n  /// This method will never cause a deadlock.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn update_policy<T>(\n    &self,\n    sub: SubjectType,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<(), AppError>\n  where\n    T: Acts,\n  {\n    let policies = act\n      .policy_acts()\n      .into_iter()\n      .map(|act| vec![sub.policy_subject(), obj.policy_object(), act])\n      .collect::<Vec<Vec<_>>>();\n\n    info!(\"[access control v2]: queuing add policy:{:?}\", policies);\n    // Generate new generation number\n    let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1;\n\n    // Extract subject-object keys\n    let subject = sub.policy_subject();\n    let object = obj.policy_object();\n    let subject_object_keys = vec![(subject, object)];\n\n    // Add to pending operations\n    {\n      let mut pending = self.pending_operations.write().await;\n      for key in &subject_object_keys {\n        pending.insert(key.clone());\n      }\n    }\n\n    let (tx, rx) = tokio::sync::oneshot::channel();\n    self\n      .send_command_with_metrics(PolicyCommand::AddPolicies {\n        policies,\n        generation,\n        subject_object_keys,\n        response: tx,\n      })\n      .await?;\n\n    let result = rx\n      .await\n      .map_err(|_| AppError::Internal(anyhow!(\"Policy update response dropped\")))?;\n    trace!(\n      \"[access control v2]: Received policy update response: {:?}\",\n      result\n    );\n    result\n  }\n\n  /// Remove policies for a subject and object type.\n  pub async fn remove_policy(\n    &self,\n    sub: SubjectType,\n    object_type: ObjectType,\n  ) -> Result<(), AppError> {\n    // First, get the policies to remove by reading\n    let policies_for_user_on_object = {\n      let enforcer = self.enforcer.read().await;\n      policies_for_subject_with_given_object(sub.clone(), object_type.clone(), &enforcer).await\n    };\n\n    event!(\n      tracing::Level::INFO,\n      \"[access control v2]: queuing remove policy:subject={}, object={}, policies={:?}\",\n      sub.policy_subject(),\n      object_type.policy_object(),\n      policies_for_user_on_object\n    );\n\n    if policies_for_user_on_object.is_empty() {\n      return Ok(());\n    }\n\n    // Generate new generation number\n    let generation = self.generation.fetch_add(1, Ordering::Relaxed) + 1;\n    let subject = sub.policy_subject();\n    let object = object_type.policy_object();\n    let subject_object_keys = vec![(subject, object)];\n\n    // Add to pending operations\n    {\n      let mut pending = self.pending_operations.write().await;\n      for key in &subject_object_keys {\n        pending.insert(key.clone());\n      }\n    }\n\n    let (tx, rx) = tokio::sync::oneshot::channel();\n    self\n      .send_command_with_metrics(PolicyCommand::RemovePolicies {\n        policies: policies_for_user_on_object,\n        generation,\n        subject_object_keys,\n        response: tx,\n      })\n      .await?;\n\n    let result = rx\n      .await\n      .map_err(|_| AppError::Internal(anyhow!(\"Policy update response dropped\")))?;\n    trace!(\n      \"[access control v2]: Received policy removal response: {:?}\",\n      result\n    );\n    result\n  }\n\n  /// Enforces an access control policy with eventual consistency.\n  /// - `Eventual`: Returns immediately with potentially stale data (fastest)\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn enforce_policy<T>(\n    &self,\n    uid: &i64,\n    obj: ObjectType,\n    act: T,\n  ) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self\n      .enforce_policy_with_consistency(uid, obj, act, ConsistencyMode::Eventual)\n      .await\n  }\n\n  /// Enforces an access control policy with configurable consistency guarantees.\n  /// - `Eventual`: Returns immediately with potentially stale data (fastest)\n  /// - `Strong`: Waits for all pending updates before checking (most consistent)\n  /// - `BoundedStrong`: Waits only for updates affecting this subject/object pair (balanced)\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn enforce_policy_with_consistency<T>(\n    &self,\n    uid: &i64,\n    obj: ObjectType,\n    act: T,\n    consistency: ConsistencyMode,\n  ) -> Result<bool, AppError>\n  where\n    T: Acts,\n  {\n    self\n      .metrics_state\n      .total_read_enforce_result\n      .fetch_add(1, Ordering::Relaxed);\n\n    match consistency {\n      ConsistencyMode::Eventual => {\n        // No waiting, proceed immediately\n      },\n      ConsistencyMode::Strong => {\n        // Wait for all pending operations to complete\n        let current_gen = self.generation.load(Ordering::Acquire);\n        self.wait_for_generation(current_gen, None).await?;\n      },\n      ConsistencyMode::BoundedStrong { timeout_ms } => {\n        // Check if there are pending operations for this subject/object\n        let subject = uid.to_string();\n        let object = obj.policy_object();\n        let key = (subject, object);\n\n        let has_pending = {\n          let pending = self.pending_operations.read().await;\n          pending.contains(&key)\n        };\n\n        if has_pending {\n          // Wait for those specific operations to complete\n          let current_gen = self.generation.load(Ordering::Acquire);\n          self\n            .wait_for_generation(current_gen, Some(Duration::from_millis(timeout_ms)))\n            .await?;\n        }\n      },\n    }\n\n    let policy_request = PolicyRequest::new(*uid, obj, act);\n    let policy = policy_request.to_policy();\n\n    let result = self\n      .enforcer\n      .read()\n      .await\n      .enforce(policy)\n      .map_err(|e| AppError::Internal(anyhow!(\"enforce: {e:?}\")))?;\n\n    Ok(result)\n  }\n\n  /// Wait for a specific generation to be processed\n  async fn wait_for_generation(\n    &self,\n    target_generation: u64,\n    timeout_duration: Option<Duration>,\n  ) -> Result<(), AppError> {\n    let timeout_duration = timeout_duration.unwrap_or(Duration::from_secs(5));\n    let wait_future = async {\n      loop {\n        let notified = self.generation_notify.notified();\n        let processed = self.processed_generation.load(Ordering::Acquire);\n        if processed >= target_generation {\n          return Ok(());\n        }\n\n        notified.await;\n      }\n    };\n\n    match timeout(timeout_duration, wait_future).await {\n      Ok(result) => result,\n      Err(_) => {\n        error!(\n          \"[access control v2]: target_generation={}, current_generation={}, pending_operation={}\",\n          target_generation,\n          self.processed_generation.load(Ordering::Acquire),\n          self.pending_operations.read().await.len(),\n        );\n\n        Err(AppError::Internal(anyhow!(\n          \"Timed out waiting for policy consistency after {}ms\",\n          timeout_duration.as_millis()\n        )))\n      },\n    }\n  }\n\n  pub async fn shutdown(&self) -> Result<(), AppError> {\n    self\n      .send_command_with_metrics(PolicyCommand::Shutdown)\n      .await?;\n    Ok(())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::{\n    act::Action,\n    casbin::access::{casbin_model, cmp_role_or_level},\n    entity::{ObjectType, SubjectType},\n  };\n  use casbin::{function_map::OperatorFunction, prelude::*};\n  use database_entity::dto::AFRole;\n  use std::sync::Arc;\n  use tokio::sync::Barrier;\n\n  async fn test_enforcer_v2() -> AFEnforcerV2 {\n    let model = casbin_model().await.unwrap();\n    let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default())\n      .await\n      .unwrap();\n\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n    AFEnforcerV2::new(enforcer).await.unwrap()\n  }\n\n  #[tokio::test]\n  async fn test_v2_no_deadlock_scenario() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 1;\n    let workspace_id = \"v2_test_workspace\";\n\n    // This would deadlock in V1, but works fine in V2\n    let enforcer_clone = Arc::clone(&enforcer);\n\n    // Update policy and immediately enforce multiple times\n    enforcer_clone\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Member,\n      )\n      .await\n      .unwrap();\n\n    // Can immediately enforce without any deadlock risk\n    let can_read = enforcer_clone\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        Action::Read,\n      )\n      .await\n      .unwrap();\n\n    assert!(can_read, \"User should be able to read\");\n\n    // Clean up\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_concurrent_operations() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let barrier = Arc::new(Barrier::new(20));\n    let mut handles = Vec::new();\n\n    // Mix of concurrent updates and enforces\n    for i in 0..20 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let uid = 1000 + i;\n        let workspace_id = format!(\"workspace_{}\", i % 5);\n\n        if i % 2 == 0 {\n          // Update policy\n          enforcer_clone\n            .update_policy(\n              SubjectType::User(uid),\n              ObjectType::Workspace(workspace_id.clone()),\n              AFRole::Member,\n            )\n            .await\n            .expect(\"Failed to update policy\");\n        }\n\n        // Always try to enforce (may succeed or fail based on timing)\n        let _ = enforcer_clone\n          .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n          .await;\n\n        \"Success\"\n      });\n\n      handles.push(handle);\n    }\n\n    // All operations should complete without deadlock\n    for handle in handles {\n      let result = handle.await.unwrap();\n      assert_eq!(result, \"Success\");\n    }\n\n    // Clean up\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_queue_ordering() {\n    let enforcer = test_enforcer_v2().await;\n    let uid = 2000;\n    let workspace_id = \"order_test\";\n\n    // Rapid sequence of operations\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n\n    enforcer\n      .remove_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n      )\n      .await\n      .unwrap();\n\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n\n    // Should end up with Guest role\n    let has_guest = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n    assert!(has_guest);\n\n    let has_owner = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n    assert!(!has_owner);\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_queue_capacity_limits() {\n    // Set a very small channel capacity via environment variable\n    std::env::set_var(\"ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY\", \"2\");\n\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let barrier = Arc::new(Barrier::new(10));\n    let mut handles = Vec::new();\n\n    // Try to send many commands at once to fill the queue\n    for i in 0..10 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        let uid = 3000 + i;\n        let workspace_id = format!(\"capacity_test_{}\", i);\n\n        // This might block or timeout if queue is full\n        let result = enforcer_clone\n          .update_policy(\n            SubjectType::User(uid),\n            ObjectType::Workspace(workspace_id),\n            AFRole::Member,\n          )\n          .await;\n\n        result.is_ok()\n      });\n\n      handles.push(handle);\n    }\n\n    let mut success_count = 0;\n    for handle in handles {\n      if handle.await.unwrap() {\n        success_count += 1;\n      }\n    }\n\n    // All should eventually succeed since the processor is draining the queue\n    assert_eq!(\n      success_count, 10,\n      \"All operations should eventually succeed\"\n    );\n\n    // Check metrics for channel full events\n    let channel_full_events = enforcer\n      .metrics_state\n      .policy_channel_full_events\n      .load(Ordering::Relaxed);\n\n    assert!(\n      channel_full_events > 0,\n      \"Should have experienced channel full events with small capacity\"\n    );\n\n    // Clean up\n    enforcer.shutdown().await.unwrap();\n    std::env::remove_var(\"ACCESS_CONTROL_POLICY_CHANNEL_CAPACITY\");\n  }\n\n  #[tokio::test]\n  async fn test_v2_policy_query_consistency_during_rapid_updates() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 4000;\n    let workspace_id = \"consistency_test\";\n\n    // This test demonstrates that rapid updates can lead to multiple policies\n    // In a real application, you would typically remove the old role before adding a new one\n\n    // Start with a clean slate - remove any existing policies\n    let _ = enforcer\n      .remove_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n      )\n      .await;\n\n    // Do sequential updates with proper cleanup to ensure consistency\n    for i in 0..10 {\n      let role = if i % 2 == 0 {\n        AFRole::Owner\n      } else {\n        AFRole::Guest\n      };\n\n      // Remove existing policy first\n      let _ = enforcer\n        .remove_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(workspace_id.to_string()),\n        )\n        .await;\n\n      // Then add new policy\n      enforcer\n        .update_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(workspace_id.to_string()),\n          role,\n        )\n        .await\n        .unwrap();\n    }\n\n    // Small delay to ensure queue is processed\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Final state should be consistent - check which role is actually set\n    let has_owner = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n\n    let has_guest = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n\n    println!(\n      \"After sequential updates with cleanup: has_owner={}, has_guest={}\",\n      has_owner, has_guest\n    );\n\n    // Now we should have exactly one role\n    let role_count = if has_owner { 1 } else { 0 } + if has_guest { 1 } else { 0 };\n    assert_eq!(\n      role_count, 1,\n      \"Should have exactly one role active after proper updates, but found {} roles\",\n      role_count\n    );\n\n    // The last update was Guest (i=9, odd number)\n    assert!(\n      !has_owner && has_guest,\n      \"Should have Guest role as the final state\"\n    );\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_multiple_policies_accumulation() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 4100;\n    let workspace_id = \"accumulation_test\";\n\n    // Start clean\n    let _ = enforcer\n      .remove_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n      )\n      .await;\n\n    // Add multiple roles without cleanup - demonstrates accumulation\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n\n    // Small delay to ensure policies are applied\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Check both roles\n    let has_owner = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n\n    let has_guest = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n\n    // Both roles should be active due to accumulation\n    assert!(\n      has_owner && has_guest,\n      \"Both roles should be active when added without cleanup\"\n    );\n\n    // User should have highest permission (can delete)\n    let can_delete = enforcer\n      .enforce_policy(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        Action::Delete,\n      )\n      .await\n      .unwrap();\n\n    assert!(\n      can_delete,\n      \"User should be able to delete with Owner role active\"\n    );\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_shutdown_with_pending_operations() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n\n    // Send many operations\n    let mut handles = Vec::new();\n    for i in 0..50 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let handle = tokio::spawn(async move {\n        let uid = 5000 + i;\n        let workspace_id = format!(\"shutdown_test_{}\", i);\n\n        enforcer_clone\n          .update_policy(\n            SubjectType::User(uid),\n            ObjectType::Workspace(workspace_id),\n            AFRole::Member,\n          )\n          .await\n      });\n      handles.push(handle);\n    }\n\n    // Immediately shutdown\n    tokio::time::sleep(Duration::from_millis(10)).await;\n    enforcer.shutdown().await.unwrap();\n\n    // Operations might fail after shutdown\n    let mut success_count = 0;\n    let mut failure_count = 0;\n\n    for handle in handles {\n      match handle.await.unwrap() {\n        Ok(_) => success_count += 1,\n        Err(_) => failure_count += 1,\n      }\n    }\n\n    println!(\n      \"Shutdown test: {} succeeded, {} failed\",\n      success_count, failure_count\n    );\n\n    // At least some operations should have succeeded before shutdown\n    assert!(\n      success_count > 0,\n      \"Some operations should succeed before shutdown\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_v2_concurrent_read_write_race_conditions() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 6000;\n    let workspace_id = \"race_test\";\n\n    // Initial setup\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Guest,\n      )\n      .await\n      .unwrap();\n\n    let barrier = Arc::new(Barrier::new(40));\n    let mut handles = Vec::new();\n\n    for i in 0..1000 {\n      let enforcer_clone = Arc::clone(&enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n      let ws_id = workspace_id.to_string();\n\n      let handle = tokio::spawn(async move {\n        barrier_clone.wait().await;\n\n        if i % 2 == 0 {\n          // Writer: alternate between Owner and Member\n          let role = if (i / 2) % 2 == 0 {\n            AFRole::Owner\n          } else {\n            AFRole::Member\n          };\n          enforcer_clone\n            .update_policy(SubjectType::User(uid), ObjectType::Workspace(ws_id), role)\n            .await\n            .map(|_| format!(\"Write {}\", i))\n        } else {\n          // Reader: check current permissions\n          let can_delete = enforcer_clone\n            .enforce_policy_with_consistency(\n              &uid,\n              ObjectType::Workspace(ws_id),\n              Action::Delete,\n              ConsistencyMode::Strong,\n            )\n            .await?;\n          Ok(format!(\"Read {}: can_delete={}\", i, can_delete))\n        }\n      });\n\n      handles.push(handle);\n    }\n\n    // Collect all results\n    let mut results = Vec::new();\n    for handle in handles {\n      match handle.await.unwrap() {\n        Ok(result) => results.push(result),\n        Err(e) => panic!(\"Operation failed: {:?}\", e),\n      }\n    }\n\n    // All operations should complete successfully\n    assert_eq!(results.len(), 1000, \"All operations should complete\");\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_policy_removal_during_enforcement() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 7000;\n    let workspace_id = \"removal_race_test\";\n\n    // Setup initial permission\n    enforcer\n      .update_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n\n    let barrier = Arc::new(Barrier::new(3));\n\n    // Task 1: Check permission\n    let enforcer1 = Arc::clone(&enforcer);\n    let barrier1 = Arc::clone(&barrier);\n    let ws_id1 = workspace_id.to_string();\n    let check_handle = tokio::spawn(async move {\n      barrier1.wait().await;\n\n      // Check multiple times to increase chance of race\n      let mut results = Vec::new();\n      for _ in 0..10 {\n        let can_delete = enforcer1\n          .enforce_policy(&uid, ObjectType::Workspace(ws_id1.clone()), Action::Delete)\n          .await\n          .unwrap();\n        results.push(can_delete);\n        tokio::time::sleep(Duration::from_micros(100)).await;\n      }\n      results\n    });\n\n    // Task 2: Remove permission\n    let enforcer2 = Arc::clone(&enforcer);\n    let barrier2 = Arc::clone(&barrier);\n    let ws_id2 = workspace_id.to_string();\n    let remove_handle = tokio::spawn(async move {\n      barrier2.wait().await;\n\n      // Small delay to let some checks happen first\n      tokio::time::sleep(Duration::from_millis(2)).await;\n\n      enforcer2\n        .remove_policy(SubjectType::User(uid), ObjectType::Workspace(ws_id2))\n        .await\n        .unwrap();\n    });\n\n    // Task 3: Re-add with different permission\n    let enforcer3 = Arc::clone(&enforcer);\n    let barrier3 = Arc::clone(&barrier);\n    let ws_id3 = workspace_id.to_string();\n    let readd_handle = tokio::spawn(async move {\n      barrier3.wait().await;\n\n      // Delay to happen after removal\n      tokio::time::sleep(Duration::from_millis(5)).await;\n\n      enforcer3\n        .update_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(ws_id3),\n          AFRole::Guest,\n        )\n        .await\n        .unwrap();\n    });\n\n    // Wait for all tasks\n    let check_results = check_handle.await.unwrap();\n    remove_handle.await.unwrap();\n    readd_handle.await.unwrap();\n\n    // Results should transition from true to false\n    println!(\n      \"Permission check results during removal: {:?}\",\n      check_results\n    );\n\n    // Early checks should be true, later ones false\n    assert!(check_results[0], \"Initial checks should show permission\");\n    assert!(\n      !check_results[check_results.len() - 1],\n      \"Final checks should show no delete permission (Guest role)\"\n    );\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_high_throughput_mixed_operations() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let operation_count = 1000;\n    let user_count = 10;\n    let workspace_count = 5;\n\n    let start_time = std::time::Instant::now();\n    let mut handles = Vec::new();\n\n    for i in 0..operation_count {\n      let enforcer_clone = Arc::clone(&enforcer);\n\n      let handle = tokio::spawn(async move {\n        let uid = 8000 + (i % user_count);\n        let workspace_id = format!(\"high_throughput_ws_{}\", i % workspace_count);\n\n        match i % 4 {\n          0 => {\n            // Add policy\n            enforcer_clone\n              .update_policy(\n                SubjectType::User(uid),\n                ObjectType::Workspace(workspace_id),\n                AFRole::Member,\n              )\n              .await\n              .map(|_| \"add\")\n          },\n          1 => {\n            // Check permission\n            enforcer_clone\n              .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n              .await\n              .map(|_| \"check\")\n          },\n          2 => {\n            // Update policy\n            enforcer_clone\n              .update_policy(\n                SubjectType::User(uid),\n                ObjectType::Workspace(workspace_id),\n                AFRole::Owner,\n              )\n              .await\n              .map(|_| \"update\")\n          },\n          _ => {\n            // Remove policy\n            enforcer_clone\n              .remove_policy(SubjectType::User(uid), ObjectType::Workspace(workspace_id))\n              .await\n              .map(|_| \"remove\")\n          },\n        }\n      });\n\n      handles.push(handle);\n    }\n\n    // Wait for all operations\n    let mut success_count = 0;\n    for handle in handles {\n      if handle.await.unwrap().is_ok() {\n        success_count += 1;\n      }\n    }\n\n    let elapsed = start_time.elapsed();\n    let ops_per_sec = (operation_count as f64) / elapsed.as_secs_f64();\n\n    println!(\n      \"High throughput test: {} operations in {:?} ({:.0} ops/sec)\",\n      operation_count, elapsed, ops_per_sec\n    );\n\n    assert_eq!(\n      success_count, operation_count,\n      \"All operations should succeed\"\n    );\n    assert!(ops_per_sec > 100.0, \"Should handle at least 100 ops/sec\");\n\n    // Check metrics\n    let total_attempts = enforcer\n      .metrics_state\n      .policy_send_attempts\n      .load(Ordering::Relaxed);\n    let total_failures = enforcer\n      .metrics_state\n      .policy_send_failures\n      .load(Ordering::Relaxed);\n\n    println!(\n      \"Metrics: {} attempts, {} failures\",\n      total_attempts, total_failures\n    );\n    assert_eq!(total_failures, 0, \"Should have no send failures\");\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_empty_policy_removal() {\n    let enforcer = test_enforcer_v2().await;\n    let uid = 9000;\n    let workspace_id = \"empty_removal_test\";\n\n    // Try to remove non-existent policy\n    let result = enforcer\n      .remove_policy(\n        SubjectType::User(uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n      )\n      .await;\n\n    // Should succeed even with no policies to remove\n    assert!(\n      result.is_ok(),\n      \"Removing non-existent policy should not error\"\n    );\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_policy_enforcement_accuracy() {\n    let enforcer = test_enforcer_v2().await;\n    let uid = 10000;\n    let workspace_id = \"accuracy_test\";\n\n    // Test different role levels and their permissions\n    // Based on load_group_policies in access.rs:\n    // - Owner: can Delete, Write, Read\n    // - Member: can Write, Read\n    // - Guest: can Write, Read\n    let test_cases = vec![\n      (\n        AFRole::Owner,\n        vec![Action::Read, Action::Write, Action::Delete],\n        vec![true, true, true],\n      ),\n      (\n        AFRole::Member,\n        vec![Action::Read, Action::Write, Action::Delete],\n        vec![true, true, false],\n      ),\n      (\n        AFRole::Guest,\n        vec![Action::Read, Action::Write, Action::Delete],\n        vec![true, true, false],\n      ), // Guest CAN write!\n    ];\n\n    for (role, actions, expected_results) in test_cases {\n      // Clean slate - remove any existing policies\n      let _ = enforcer\n        .remove_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(workspace_id.to_string()),\n        )\n        .await;\n\n      // Set role\n      enforcer\n        .update_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(workspace_id.to_string()),\n          role.clone(),\n        )\n        .await\n        .unwrap();\n\n      // Small delay to ensure policy is applied\n      tokio::time::sleep(Duration::from_millis(10)).await;\n\n      // Check each action\n      for (action, expected) in actions.into_iter().zip(expected_results) {\n        let result = enforcer\n          .enforce_policy(\n            &uid,\n            ObjectType::Workspace(workspace_id.to_string()),\n            action.clone(),\n          )\n          .await\n          .unwrap();\n\n        assert_eq!(\n          result, expected,\n          \"Role {:?} with action {:?} should be {}\",\n          role, action, expected\n        );\n      }\n    }\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_consistency_modes() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 11000;\n    let workspace_id = \"consistency_mode_test\";\n\n    // Test 1: Eventual consistency (default) - might return stale result\n    let enforcer1 = Arc::clone(&enforcer);\n    let eventual_result = tokio::spawn(async move {\n      // Update and immediately check with eventual consistency\n      enforcer1\n        .update_policy(\n          SubjectType::User(uid),\n          ObjectType::Workspace(workspace_id.to_string()),\n          AFRole::Owner,\n        )\n        .await\n        .unwrap();\n\n      // This might return false if policy hasn't been processed yet\n      enforcer1\n        .enforce_policy(\n          &uid,\n          ObjectType::Workspace(workspace_id.to_string()),\n          Action::Delete,\n        )\n        .await\n        .unwrap()\n    });\n\n    // Test 2: Strong consistency - always returns correct result\n    let enforcer2 = Arc::clone(&enforcer);\n    let strong_result = tokio::spawn(async move {\n      // Update and check with strong consistency\n      enforcer2\n        .update_policy(\n          SubjectType::User(uid + 1),\n          ObjectType::Workspace(format!(\"{}_2\", workspace_id)),\n          AFRole::Owner,\n        )\n        .await\n        .unwrap();\n\n      // This will always return true because it waits for the update\n      enforcer2\n        .enforce_policy_with_consistency(\n          &(uid + 1),\n          ObjectType::Workspace(format!(\"{}_2\", workspace_id)),\n          Action::Delete,\n          ConsistencyMode::Strong,\n        )\n        .await\n        .unwrap()\n    });\n\n    // Test 3: Bounded strong consistency - waits only for relevant updates\n    let enforcer3 = Arc::clone(&enforcer);\n    let bounded_result = tokio::spawn(async move {\n      // Update policy\n      enforcer3\n        .update_policy(\n          SubjectType::User(uid + 2),\n          ObjectType::Workspace(format!(\"{}_3\", workspace_id)),\n          AFRole::Member,\n        )\n        .await\n        .unwrap();\n\n      // Check with bounded consistency (waits up to 1 second)\n      enforcer3\n        .enforce_policy_with_consistency(\n          &(uid + 2),\n          ObjectType::Workspace(format!(\"{}_3\", workspace_id)),\n          Action::Write,\n          ConsistencyMode::BoundedStrong { timeout_ms: 1000 },\n        )\n        .await\n        .unwrap()\n    });\n\n    let _ = eventual_result.await.unwrap();\n    let strong = strong_result.await.unwrap();\n    let bounded = bounded_result.await.unwrap();\n\n    // Strong consistency should always work\n    assert!(strong, \"Strong consistency should guarantee correct result\");\n    assert!(bounded, \"Bounded consistency should work within timeout\");\n\n    // Note: eventual_result might be true or false depending on timing\n\n    enforcer.shutdown().await.unwrap();\n  }\n\n  #[tokio::test]\n  async fn test_v2_consistency_timeout() {\n    let enforcer = Arc::new(test_enforcer_v2().await);\n    let uid = 12000;\n    let workspace_id = \"timeout_test\";\n\n    // Create a scenario where processor is slow by filling the channel\n    for i in 0..100 {\n      let _ = enforcer\n        .update_policy(\n          SubjectType::User(uid + i),\n          ObjectType::Workspace(format!(\"{}_{}\", workspace_id, i)),\n          AFRole::Member,\n        )\n        .await;\n    }\n\n    // Try to enforce with very short timeout\n    let _result = enforcer\n      .enforce_policy_with_consistency(\n        &uid,\n        ObjectType::Workspace(workspace_id.to_string()),\n        Action::Read,\n        ConsistencyMode::BoundedStrong { timeout_ms: 1 }, // 1ms timeout\n      )\n      .await;\n\n    // Should likely timeout (unless processor is very fast)\n    // Note: This is not a guarantee, just likely\n\n    enforcer.shutdown().await.unwrap();\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/mod.rs",
    "content": "pub mod access;\nmod adapter;\npub mod collab;\n\n#[cfg(test)]\nmod enforcer;\npub mod enforcer_v2;\n#[cfg(test)]\nmod performance_comparison_tests;\nmod redis_cache;\nmod util;\npub mod workspace;\n"
  },
  {
    "path": "libs/access-control/src/casbin/performance_comparison_tests.rs",
    "content": "/// Performance comparison tests between AFEnforcer V1 and V2\n/// === Baseline enforce_policy Performance (No Concurrent Writes) ===\n/// Test configuration: 10,000 enforce_policy calls\n///\n/// | Version | Total Time | Avg per Op | Speed Ratio |\n/// |---------|------------|------------|-------------|\n/// | V1      |   54.644ms |     5.46μs | 1.00x       |\n/// | V2      |   46.127ms |     4.61μs | 1.18x       |\n/// ok\n/// test casbin::performance_comparison_tests::performance_tests::test_enforce_policy_latency_distribution ...\n/// === enforce_policy Latency Distribution ===\n/// Test configuration: 1000 individual operation latency measurements\n///\n/// | Version | P50 (μs) | P95 (μs) | P99 (μs) | Max (μs) |\n/// |---------|----------|----------|----------|----------|\n/// | V1      |        6 |      201 |      313 |     1103 |\n/// | V2      |        4 |       78 |      221 |      251 |\n/// ok\n/// test casbin::performance_comparison_tests::performance_tests::test_enforce_policy_under_write_load ...\n/// === enforce_policy Performance Under Heavy Write Load ===\n/// Test configuration:\n/// - 4 reader threads × 1000 reads = 4000 total reads\n/// - 2 writer threads × 100 writes = 200 total writes\n///\n/// | Version | Avg Read Time | Speed Ratio |\n/// |---------|---------------|-------------|\n/// | V1      |        8.50ms | 1.00x       |\n/// | V2      |      140.00ms | 0.06x       |\n/// ok\n/// test casbin::performance_comparison_tests::performance_tests::test_mixed_workload_throughput ...\n/// === Mixed Workload Throughput Test ===\n/// Test configuration:\n/// - Duration: 2 seconds\n/// - Concurrent threads: 8\n/// - Workload mix: 90% reads, 10% writes\n///\n/// | Version | Total Ops | Throughput | Speed Ratio |\n/// |---------|-----------|------------|-------------|\n/// | V1      |      2216 |    1108 ops/s | 1.00x       |\n/// | V2      |     11331 |    5666 ops/s | 5.11x       |\n///\n/// cargo test -p access-control -- --ignored performance_tests --nocapture\n#[cfg(test)]\nmod performance_tests {\n  use crate::{\n    act::Action,\n    casbin::{\n      access::{casbin_model, cmp_role_or_level},\n      enforcer::AFEnforcer,\n      enforcer_v2::AFEnforcerV2,\n    },\n    entity::{ObjectType, SubjectType},\n  };\n  use casbin::{function_map::OperatorFunction, prelude::*};\n  use database_entity::dto::AFRole;\n  use std::sync::Arc;\n  use std::time::{Duration, Instant};\n  use tokio::sync::Barrier;\n\n  /// Helper to create V1 enforcer\n  async fn create_v1_enforcer() -> AFEnforcer {\n    let model = casbin_model().await.unwrap();\n    let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default())\n      .await\n      .unwrap();\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n    AFEnforcer::new(enforcer).await.unwrap()\n  }\n\n  /// Helper to create V2 enforcer\n  async fn create_v2_enforcer() -> AFEnforcerV2 {\n    let model = casbin_model().await.unwrap();\n    let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default())\n      .await\n      .unwrap();\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n    AFEnforcerV2::new(enforcer).await.unwrap()\n  }\n\n  /// Test 1: Baseline Performance\n  ///\n  /// This test measures pure enforce_policy performance without any concurrent writes.\n  /// It establishes a baseline to show that V2 has minimal overhead even in the simplest case.\n  ///\n  /// Expected Results:\n  /// - V1: ~6-7μs per operation\n  /// - V2: ~4-5μs per operation  \n  /// - V2 should be 1.3-1.5x faster\n  ///\n  /// Why V2 is faster even without contention:\n  /// - No retry logic needed (V1's retry_write adds overhead)\n  /// - Simpler code path for reads\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  #[ignore]\n  async fn test_enforce_policy_baseline_performance() {\n    println!(\"\\n=== Baseline enforce_policy Performance (No Concurrent Writes) ===\");\n    println!(\"Test configuration: 10,000 enforce_policy calls\\n\");\n\n    // Setup V1\n    let v1_enforcer = Arc::new(create_v1_enforcer().await);\n    for i in 0..100 {\n      v1_enforcer\n        .update_policy(\n          SubjectType::User(i),\n          ObjectType::Workspace(format!(\"workspace_{}\", i)),\n          AFRole::Member,\n        )\n        .await\n        .unwrap();\n    }\n\n    // Setup V2\n    let v2_enforcer = Arc::new(create_v2_enforcer().await);\n    for i in 0..100 {\n      v2_enforcer\n        .update_policy(\n          SubjectType::User(i),\n          ObjectType::Workspace(format!(\"workspace_{}\", i)),\n          AFRole::Member,\n        )\n        .await\n        .unwrap();\n    }\n\n    // Wait for V2 background task to complete\n    tokio::time::sleep(Duration::from_millis(100)).await;\n\n    // Measure V1 performance\n    let iterations = 10000;\n    let start = Instant::now();\n    for i in 0..iterations {\n      let uid = (i % 100) as i64;\n      let workspace_id = format!(\"workspace_{}\", uid);\n      let _ = v1_enforcer\n        .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n        .await\n        .unwrap();\n    }\n    let v1_duration = start.elapsed();\n    let v1_avg = v1_duration.as_micros() as f64 / iterations as f64;\n\n    // Measure V2 performance\n    let start = Instant::now();\n    for i in 0..iterations {\n      let uid = (i % 100) as i64;\n      let workspace_id = format!(\"workspace_{}\", uid);\n      let _ = v2_enforcer\n        .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n        .await\n        .unwrap();\n    }\n    let v2_duration = start.elapsed();\n    let v2_avg = v2_duration.as_micros() as f64 / iterations as f64;\n\n    println!(\"| Version | Total Time | Avg per Op | Speed Ratio |\");\n    println!(\"|---------|------------|------------|-------------|\");\n    println!(\n      \"| V1      | {:>10.3?} | {:>8.2}μs | 1.00x       |\",\n      v1_duration, v1_avg\n    );\n    println!(\n      \"| V2      | {:>10.3?} | {:>8.2}μs | {:.2}x       |\",\n      v2_duration,\n      v2_avg,\n      v1_avg / v2_avg\n    );\n\n    // Cleanup\n    v2_enforcer.shutdown().await.unwrap();\n  }\n\n  /// Test 2: Performance Under Write Load\n  ///\n  /// This test measures how reads perform when there are concurrent writes happening.\n  /// This is where V2's architecture should shine - reads should never be blocked by writes.\n  ///\n  /// Test setup:\n  /// - 4 reader threads doing 1000 reads each\n  /// - 2 writer threads doing 100 writes each\n  ///\n  /// Expected behavior:\n  /// - V1: Readers will be blocked when writers hold the lock\n  /// - V2: Readers continue unimpeded while writes are queued\n  ///\n  /// Note: The test showing V2 slower might be due to:\n  /// - Test setup issues (too many policies being created)\n  /// - Background task falling behind\n  /// - Need to tune queue size for heavy write loads\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 6)]\n  #[ignore]\n  async fn test_enforce_policy_under_write_load() {\n    println!(\"\\n=== enforce_policy Performance Under Heavy Write Load ===\");\n    println!(\"Test configuration:\");\n    println!(\"- 4 reader threads × 1000 reads = 4000 total reads\");\n    println!(\"- 2 writer threads × 100 writes = 200 total writes\\n\");\n\n    let v1_enforcer = Arc::new(create_v1_enforcer().await);\n    let v2_enforcer = Arc::new(create_v2_enforcer().await);\n\n    // Test parameters\n    let read_threads = 4;\n    let write_threads = 2;\n    let reads_per_thread = 1000;\n    let writes_per_thread = 100;\n\n    // V1 Test\n    let barrier = Arc::new(Barrier::new(read_threads + write_threads));\n    let mut handles = Vec::new();\n\n    // Spawn reader threads\n    for thread_id in 0..read_threads {\n      let enforcer = Arc::clone(&v1_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n\n        for i in 0..reads_per_thread {\n          let uid = ((thread_id * 1000 + i) % 100) as i64;\n          let workspace_id = format!(\"workspace_{}\", uid);\n          let _ = enforcer\n            .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n            .await\n            .unwrap();\n        }\n\n        start.elapsed()\n      }));\n    }\n\n    // Spawn writer threads\n    for thread_id in 0..write_threads {\n      let enforcer = Arc::clone(&v1_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n\n        for i in 0..writes_per_thread {\n          let uid = (thread_id * 1000 + i) as i64;\n          let workspace_id = format!(\"workspace_write_{}\", i);\n          enforcer\n            .update_policy(\n              SubjectType::User(uid),\n              ObjectType::Workspace(workspace_id),\n              AFRole::Member,\n            )\n            .await\n            .unwrap();\n        }\n\n        start.elapsed()\n      }));\n    }\n\n    // Collect V1 results\n    let mut v1_read_times = Vec::new();\n    let mut v1_write_times = Vec::new();\n    for (idx, handle) in handles.into_iter().enumerate() {\n      let duration = handle.await.unwrap();\n      if idx < read_threads {\n        v1_read_times.push(duration);\n      } else {\n        v1_write_times.push(duration);\n      }\n    }\n\n    // V2 Test\n    let barrier = Arc::new(Barrier::new(read_threads + write_threads));\n    let mut handles = Vec::new();\n\n    // Spawn reader threads\n    for thread_id in 0..read_threads {\n      let enforcer = Arc::clone(&v2_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n\n        for i in 0..reads_per_thread {\n          let uid = ((thread_id * 1000 + i) % 100) as i64;\n          let workspace_id = format!(\"workspace_{}\", uid);\n          let _ = enforcer\n            .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n            .await\n            .unwrap();\n        }\n\n        start.elapsed()\n      }));\n    }\n\n    // Spawn writer threads\n    for thread_id in 0..write_threads {\n      let enforcer = Arc::clone(&v2_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n\n        for i in 0..writes_per_thread {\n          let uid = (thread_id * 1000 + i) as i64;\n          let workspace_id = format!(\"workspace_write_{}\", i);\n          enforcer\n            .update_policy(\n              SubjectType::User(uid),\n              ObjectType::Workspace(workspace_id),\n              AFRole::Member,\n            )\n            .await\n            .unwrap();\n        }\n\n        start.elapsed()\n      }));\n    }\n\n    // Collect V2 results\n    let mut v2_read_times = Vec::new();\n    let mut v2_write_times = Vec::new();\n    for (idx, handle) in handles.into_iter().enumerate() {\n      let duration = handle.await.unwrap();\n      if idx < read_threads {\n        v2_read_times.push(duration);\n      } else {\n        v2_write_times.push(duration);\n      }\n    }\n\n    // Print results\n    let v1_avg_read =\n      v1_read_times.iter().map(|d| d.as_millis()).sum::<u128>() as f64 / v1_read_times.len() as f64;\n    let v2_avg_read =\n      v2_read_times.iter().map(|d| d.as_millis()).sum::<u128>() as f64 / v2_read_times.len() as f64;\n\n    println!(\"| Version | Avg Read Time | Speed Ratio |\");\n    println!(\"|---------|---------------|-------------|\");\n    println!(\"| V1      | {:>11.2}ms | 1.00x       |\", v1_avg_read);\n    println!(\n      \"| V2      | {:>11.2}ms | {:.2}x       |\",\n      v2_avg_read,\n      v1_avg_read / v2_avg_read\n    );\n\n    // Cleanup\n    v2_enforcer.shutdown().await.unwrap();\n  }\n\n  /// Test 3: Latency Distribution\n  ///\n  /// This test measures the distribution of latencies to understand consistency.\n  /// While average performance is important, tail latencies (P95, P99) are critical\n  /// for user experience - users notice the slowest requests.\n  ///\n  /// Expected Results:\n  /// - P50 (median): Should be similar for both\n  /// - P95/P99: V2 should be significantly better\n  /// - Max: V2 should avoid the multi-millisecond spikes V1 can have\n  ///\n  /// From actual runs:\n  /// - V1 max: 4,343μs (4.3ms!) - clear evidence of lock contention\n  /// - V2 max: 202μs - much more predictable\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  #[ignore]\n  async fn test_enforce_policy_latency_distribution() {\n    println!(\"\\n=== enforce_policy Latency Distribution ===\");\n    println!(\"Test configuration: 1000 individual operation latency measurements\\n\");\n\n    let v1_enforcer = Arc::new(create_v1_enforcer().await);\n    let v2_enforcer = Arc::new(create_v2_enforcer().await);\n\n    // Pre-populate some policies\n    for i in 0..50 {\n      v1_enforcer\n        .update_policy(\n          SubjectType::User(i),\n          ObjectType::Workspace(format!(\"workspace_{}\", i)),\n          AFRole::Member,\n        )\n        .await\n        .unwrap();\n      v2_enforcer\n        .update_policy(\n          SubjectType::User(i),\n          ObjectType::Workspace(format!(\"workspace_{}\", i)),\n          AFRole::Member,\n        )\n        .await\n        .unwrap();\n    }\n\n    // Wait for V2 to process\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Measure individual operation latencies\n    let samples = 1000;\n    let mut v1_latencies = Vec::with_capacity(samples);\n    let mut v2_latencies = Vec::with_capacity(samples);\n\n    // V1 measurements\n    for i in 0..samples {\n      let uid = (i % 50) as i64;\n      let workspace_id = format!(\"workspace_{}\", uid);\n\n      let start = Instant::now();\n      let _ = v1_enforcer\n        .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n        .await\n        .unwrap();\n      v1_latencies.push(start.elapsed().as_micros());\n    }\n\n    // V2 measurements\n    for i in 0..samples {\n      let uid = (i % 50) as i64;\n      let workspace_id = format!(\"workspace_{}\", uid);\n\n      let start = Instant::now();\n      let _ = v2_enforcer\n        .enforce_policy(&uid, ObjectType::Workspace(workspace_id), Action::Read)\n        .await\n        .unwrap();\n      v2_latencies.push(start.elapsed().as_micros());\n    }\n\n    // Calculate percentiles\n    v1_latencies.sort();\n    v2_latencies.sort();\n\n    let p50_idx = samples / 2;\n    let p95_idx = (samples * 95) / 100;\n    let p99_idx = (samples * 99) / 100;\n\n    println!(\"| Version | P50 (μs) | P95 (μs) | P99 (μs) | Max (μs) |\");\n    println!(\"|---------|----------|----------|----------|----------|\");\n    println!(\n      \"| V1      | {:>8} | {:>8} | {:>8} | {:>8} |\",\n      v1_latencies[p50_idx],\n      v1_latencies[p95_idx],\n      v1_latencies[p99_idx],\n      v1_latencies[samples - 1]\n    );\n    println!(\n      \"| V2      | {:>8} | {:>8} | {:>8} | {:>8} |\",\n      v2_latencies[p50_idx],\n      v2_latencies[p95_idx],\n      v2_latencies[p99_idx],\n      v2_latencies[samples - 1]\n    );\n\n    // Cleanup\n    v2_enforcer.shutdown().await.unwrap();\n  }\n\n  /// Test 4: Mixed Workload Throughput (Most Important Test)\n  ///\n  /// This test simulates a realistic workload with 90% reads and 10% writes,\n  /// measuring total system throughput. This is the most representative test\n  /// of real-world performance.\n  ///\n  /// Why 90/10 split?\n  /// - Most access control systems are read-heavy\n  /// - Users check permissions far more often than permissions change\n  ///\n  /// Expected Results:\n  /// - V2 should show even greater advantage with fewer writes\n  ///\n  /// This test clearly shows V2's advantage in production workloads.\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 8)]\n  #[ignore]\n  async fn test_mixed_workload_throughput() {\n    println!(\"\\n=== Mixed Workload Throughput Test ===\");\n    println!(\"Test configuration:\");\n    println!(\"- Duration: 2 seconds\");\n    println!(\"- Concurrent threads: 8\");\n    println!(\"- Workload mix: 90% reads, 10% writes\\n\");\n\n    let v1_enforcer = Arc::new(create_v1_enforcer().await);\n    let v2_enforcer = Arc::new(create_v2_enforcer().await);\n\n    let duration = Duration::from_secs(2);\n    let threads = 8;\n\n    // V1 throughput test\n    let barrier = Arc::new(Barrier::new(threads));\n    let mut handles = Vec::new();\n\n    for thread_id in 0..threads {\n      let enforcer = Arc::clone(&v1_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n        let mut operations = 0u64;\n        let mut counter = 0u64;\n\n        while start.elapsed() < duration {\n          counter += 1;\n\n          if counter % 10 == 0 {\n            // 10% writes\n            let uid = (thread_id * 1000 + counter as usize) as i64;\n            enforcer\n              .update_policy(\n                SubjectType::User(uid),\n                ObjectType::Workspace(format!(\"workspace_{}\", counter)),\n                AFRole::Member,\n              )\n              .await\n              .unwrap();\n          } else {\n            // 90% reads\n            let uid = (counter % 100) as i64;\n            let _ = enforcer\n              .enforce_policy(\n                &uid,\n                ObjectType::Workspace(format!(\"workspace_{}\", uid)),\n                Action::Read,\n              )\n              .await\n              .unwrap();\n          }\n\n          operations += 1;\n        }\n\n        operations\n      }));\n    }\n\n    let mut v1_total_ops = 0u64;\n    for handle in handles {\n      v1_total_ops += handle.await.unwrap();\n    }\n\n    // V2 throughput test\n    let barrier = Arc::new(Barrier::new(threads));\n    let mut handles = Vec::new();\n\n    for thread_id in 0..threads {\n      let enforcer = Arc::clone(&v2_enforcer);\n      let barrier_clone = Arc::clone(&barrier);\n\n      handles.push(tokio::spawn(async move {\n        barrier_clone.wait().await;\n        let start = Instant::now();\n        let mut operations = 0u64;\n        let mut counter = 0u64;\n\n        while start.elapsed() < duration {\n          counter += 1;\n\n          if counter % 10 == 0 {\n            // 10% writes\n            let uid = (thread_id * 1000 + counter as usize) as i64;\n            enforcer\n              .update_policy(\n                SubjectType::User(uid),\n                ObjectType::Workspace(format!(\"workspace_{}\", counter)),\n                AFRole::Member,\n              )\n              .await\n              .unwrap();\n          } else {\n            // 90% reads\n            let uid = (counter % 100) as i64;\n            let _ = enforcer\n              .enforce_policy(\n                &uid,\n                ObjectType::Workspace(format!(\"workspace_{}\", uid)),\n                Action::Read,\n              )\n              .await\n              .unwrap();\n          }\n\n          operations += 1;\n        }\n\n        operations\n      }));\n    }\n\n    let mut v2_total_ops = 0u64;\n    for handle in handles {\n      v2_total_ops += handle.await.unwrap();\n    }\n\n    let v1_throughput = v1_total_ops as f64 / duration.as_secs_f64();\n    let v2_throughput = v2_total_ops as f64 / duration.as_secs_f64();\n\n    println!(\"| Version | Total Ops | Throughput | Speed Ratio |\");\n    println!(\"|---------|-----------|------------|-------------|\");\n    println!(\n      \"| V1      | {:>9} | {:>7.0} ops/s | 1.00x       |\",\n      v1_total_ops, v1_throughput\n    );\n    println!(\n      \"| V2      | {:>9} | {:>7.0} ops/s | {:.2}x       |\",\n      v2_total_ops,\n      v2_throughput,\n      v2_throughput / v1_throughput\n    );\n\n    // Cleanup\n    v2_enforcer.shutdown().await.unwrap();\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/redis_cache.rs",
    "content": "use casbin::Cache;\nuse redis::{Client, Connection, IntoConnectionInfo, RedisResult};\nuse serde::{de::DeserializeOwned, Serialize};\nuse std::{hash::Hash, marker::PhantomData, sync::Mutex};\n\nconst CACHE_HKEY: &str = \"ac:cache:v1\";\nconst CACHE_TTL_SECONDS: usize = 600; // 10 minutes\n\npub struct RedisCache<K, V> {\n  conn: Mutex<Connection>,\n  _marker: PhantomData<(K, V)>,\n}\n\nimpl<K, V> RedisCache<K, V>\nwhere\n  K: Eq + Hash + Clone,\n{\n  pub fn new<T: IntoConnectionInfo>(redis_url: T) -> RedisResult<RedisCache<K, V>> {\n    let client = Client::open(redis_url)?;\n    let conn = client.get_connection()?;\n\n    Ok(RedisCache {\n      conn: Mutex::new(conn),\n      _marker: PhantomData,\n    })\n  }\n}\n\nimpl<K, V> Cache<K, V> for RedisCache<K, V>\nwhere\n  K: Eq + Hash + Send + Sync + Serialize + Clone + 'static,\n  V: Send + Sync + Clone + Serialize + DeserializeOwned + 'static,\n{\n  fn get(&self, k: &K) -> Option<V> {\n    // Get from Redis\n    if let Ok(field) = serde_json::to_string(&k) {\n      if let Ok(mut conn) = self.conn.lock() {\n        if let Ok(res) = redis::cmd(\"HGET\")\n          .arg(CACHE_HKEY)\n          .arg(&field)\n          .query::<Option<String>>(&mut *conn)\n        {\n          if let Some(data) = res.as_ref().and_then(|d| serde_json::from_str::<V>(d).ok()) {\n            return Some(data);\n          }\n        }\n      }\n    }\n\n    None\n  }\n\n  fn has(&self, k: &K) -> bool {\n    if let Ok(field) = serde_json::to_string(&k) {\n      if let Ok(mut conn) = self.conn.lock() {\n        if let Ok(res) = redis::cmd(\"HEXISTS\")\n          .arg(CACHE_HKEY)\n          .arg(&field)\n          .query::<bool>(&mut *conn)\n        {\n          return res;\n        }\n      }\n    }\n\n    false\n  }\n\n  fn set(&self, k: K, v: V) {\n    if let Ok(mut conn) = self.conn.lock() {\n      if let (Ok(field), Ok(value)) = (serde_json::to_string(&k), serde_json::to_string(&v)) {\n        let script = r#\"\n            redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])\n            redis.call('EXPIRE', KEYS[1], ARGV[3])\n            return 1\n        \"#;\n\n        let _ = redis::Script::new(script)\n          .key(CACHE_HKEY)\n          .arg(&field)\n          .arg(&value)\n          .arg(CACHE_TTL_SECONDS)\n          .invoke::<()>(&mut *conn);\n      }\n    }\n  }\n\n  fn clear(&self) {\n    // Clear Redis\n    if let Ok(mut conn) = self.conn.lock() {\n      let _ = redis::cmd(\"DEL\").arg(CACHE_HKEY).query::<bool>(&mut *conn);\n    }\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  #[ignore]\n  fn test_set_has_get_clear() {\n    // Skip test if Redis is not available\n    let cache = match RedisCache::new(\"redis://localhost:6379\") {\n      Ok(cache) => cache,\n      Err(_) => {\n        println!(\"Redis not available, skipping test\");\n        return;\n      },\n    };\n\n    let cache: RedisCache<Vec<&str>, bool> = cache;\n\n    // Clear any existing cache\n    cache.clear();\n\n    // Test set and has\n    cache.set(vec![\"alice\", \"/data1\", \"read\"], false);\n    assert!(cache.has(&vec![\"alice\", \"/data1\", \"read\"]));\n\n    // Test get\n    assert_eq!(cache.get(&vec![\"alice\", \"/data1\", \"read\"]), Some(false));\n\n    // Test clear\n    cache.clear();\n    assert_eq!(cache.get(&vec![\"alice\", \"/data1\", \"read\"]), None);\n  }\n\n  #[test]\n  #[ignore]\n  fn test_ttl_expiration() {\n    let cache = match RedisCache::new(\"redis://localhost:6379\") {\n      Ok(cache) => cache,\n      Err(_) => {\n        println!(\"Redis not available, skipping test\");\n        return;\n      },\n    };\n\n    let cache: RedisCache<String, String> = cache;\n    cache.clear();\n\n    // Set a value\n    cache.set(\"test_key\".to_string(), \"test_value\".to_string());\n\n    // Verify it exists\n    assert_eq!(\n      cache.get(&\"test_key\".to_string()),\n      Some(\"test_value\".to_string())\n    );\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/util.rs",
    "content": "use crate::casbin::access::{POLICY_FIELD_INDEX_OBJECT, POLICY_FIELD_INDEX_SUBJECT};\nuse crate::entity::{ObjectType, SubjectType};\nuse casbin::{CachedEnforcer, MgmtApi};\n\n#[inline]\npub(crate) async fn policies_for_subject_with_given_object(\n  subject: SubjectType,\n  object_type: ObjectType,\n  enforcer: &CachedEnforcer,\n) -> Vec<Vec<String>> {\n  let subject_id = subject.policy_subject();\n  let object_type_id = object_type.policy_object();\n  let policies_related_to_object =\n    enforcer.get_filtered_policy(POLICY_FIELD_INDEX_OBJECT, vec![object_type_id]);\n\n  policies_related_to_object\n    .into_iter()\n    .filter(|p| p[POLICY_FIELD_INDEX_SUBJECT] == subject_id)\n    .collect::<Vec<_>>()\n}\n\n#[cfg(test)]\npub mod tests {\n  use crate::casbin::access::{casbin_model, cmp_role_or_level};\n  use crate::casbin::enforcer_v2::AFEnforcerV2;\n  use casbin::function_map::OperatorFunction;\n  use casbin::{CoreApi, MemoryAdapter};\n  pub async fn test_enforcer_v2() -> AFEnforcerV2 {\n    let model = casbin_model().await.unwrap();\n    let mut enforcer = casbin::CachedEnforcer::new(model, MemoryAdapter::default())\n      .await\n      .unwrap();\n\n    enforcer.add_function(\"cmpRoleOrLevel\", OperatorFunction::Arg2(cmp_role_or_level));\n    AFEnforcerV2::new(enforcer).await.unwrap()\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/casbin/workspace.rs",
    "content": "use async_trait::async_trait;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse super::access::AccessControl;\nuse crate::act::Action;\nuse crate::entity::{ObjectType, SubjectType};\nuse crate::workspace::WorkspaceAccessControl;\nuse app_error::AppError;\nuse database_entity::dto::AFRole;\n\n#[derive(Clone)]\npub struct WorkspaceAccessControlImpl {\n  access_control: AccessControl,\n}\n\nimpl WorkspaceAccessControlImpl {\n  pub fn new(access_control: AccessControl) -> Self {\n    Self { access_control }\n  }\n}\n\n#[async_trait]\nimpl WorkspaceAccessControl for WorkspaceAccessControlImpl {\n  async fn enforce_role_strong(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    role: AFRole,\n  ) -> Result<(), AppError> {\n    let result = self\n      .access_control\n      .enforce_strong(uid, ObjectType::Workspace(workspace_id.to_string()), role)\n      .await;\n\n    match result {\n      Ok(true) => Ok(()),\n      Ok(false) => Err(AppError::NotEnoughPermissions),\n      Err(e) => Err(e),\n    }\n  }\n\n  async fn enforce_role_weak(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    role: AFRole,\n  ) -> Result<(), AppError> {\n    let result = self\n      .access_control\n      .enforce_weak(uid, ObjectType::Workspace(workspace_id.to_string()), role)\n      .await;\n\n    match result {\n      Ok(true) => Ok(()),\n      Ok(false) => Err(AppError::NotEnoughPermissions),\n      Err(e) => Err(e),\n    }\n  }\n\n  async fn enforce_action(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    action: Action,\n  ) -> Result<(), AppError> {\n    let result = self\n      .access_control\n      .enforce_immediately(uid, ObjectType::Workspace(workspace_id.to_string()), action)\n      .await;\n    match result {\n      Ok(true) => Ok(()),\n      Ok(false) => Err(AppError::NotEnoughPermissions),\n      Err(e) => Err(e),\n    }\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  async fn insert_role(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    role: AFRole,\n  ) -> Result<(), AppError> {\n    self\n      .access_control\n      .update_policy(\n        SubjectType::User(*uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        role,\n      )\n      .await?;\n    Ok(())\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  async fn remove_user_from_workspace(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n  ) -> Result<(), AppError> {\n    self\n      .access_control\n      .remove_policy(\n        SubjectType::User(*uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n      )\n      .await?;\n\n    self\n      .access_control\n      .remove_policy(\n        SubjectType::User(*uid),\n        ObjectType::Collab(workspace_id.to_string()),\n      )\n      .await?;\n    Ok(())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use app_error::ErrorCode;\n  use database_entity::dto::AFRole;\n  use uuid::Uuid;\n\n  use crate::casbin::util::tests::test_enforcer_v2;\n  use crate::{\n    casbin::access::AccessControl,\n    entity::{ObjectType, SubjectType},\n    workspace::WorkspaceAccessControl,\n  };\n\n  #[tokio::test]\n  pub async fn test_workspace_access_control() {\n    let enforcer = test_enforcer_v2().await;\n    let member_uid = 1;\n    let owner_uid = 2;\n    let workspace_id = Uuid::new_v4();\n    enforcer\n      .update_policy(\n        SubjectType::User(member_uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Member,\n      )\n      .await\n      .unwrap();\n    enforcer\n      .update_policy(\n        SubjectType::User(owner_uid),\n        ObjectType::Workspace(workspace_id.to_string()),\n        AFRole::Owner,\n      )\n      .await\n      .unwrap();\n    let access_control = AccessControl::with_enforcer(enforcer);\n    let workspace_access_control = super::WorkspaceAccessControlImpl::new(access_control);\n    for uid in [member_uid, owner_uid] {\n      workspace_access_control\n        .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n        .await\n        .unwrap_or_else(|_| panic!(\"Failed to enforce role for {}\", uid));\n      workspace_access_control\n        .enforce_action(&uid, &workspace_id, crate::act::Action::Read)\n        .await\n        .unwrap_or_else(|_| panic!(\"Failed to enforce action for {}\", uid));\n    }\n    let result = workspace_access_control\n      .enforce_action(&member_uid, &workspace_id, crate::act::Action::Delete)\n      .await;\n    let error_code = result.unwrap_err().code();\n    assert_eq!(error_code, ErrorCode::NotEnoughPermissions);\n    workspace_access_control\n      .enforce_action(&owner_uid, &workspace_id, crate::act::Action::Delete)\n      .await\n      .unwrap();\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/collab.rs",
    "content": "use crate::act::Action;\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse database_entity::dto::AFAccessLevel;\nuse uuid::Uuid;\n\n#[async_trait]\npub trait CollabAccessControl: Sync + Send + 'static {\n  /// Check if the user can perform the action on the collab.\n  /// Returns AppError::NotEnoughPermission if the user does not have the permission.\n  async fn enforce_action(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n    action: Action,\n  ) -> Result<(), AppError>;\n\n  /// Check if the user has the access level in the collab.\n  /// Returns AppError::NotEnoughPermission if the user does not have the access level.\n  async fn enforce_access_level(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n    access_level: AFAccessLevel,\n  ) -> Result<(), AppError>;\n\n  /// Return the access level of the user in the collab\n  async fn update_access_level_policy(\n    &self,\n    uid: &i64,\n    oid: &Uuid,\n    level: AFAccessLevel,\n  ) -> Result<(), AppError>;\n\n  async fn remove_access_level(&self, uid: &i64, oid: &Uuid) -> Result<(), AppError>;\n}\n\n#[async_trait]\npub trait RealtimeAccessControl: Sync + Send + 'static {\n  /// Return true if the user is allowed to edit collab.\n  /// This function will be called very frequently, so it should be very fast.\n  ///\n  /// The user can send the message if:\n  /// 1. user is the member of the collab object\n  /// 2. the permission level of the user is `ReadAndWrite` or `FullAccess`\n  /// 3. If the collab object is not found which means the collab object is created by the user.\n  async fn can_write_collab(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n  ) -> Result<bool, AppError>;\n\n  /// Return true if the user is allowed to observe the changes of given collab.\n  /// This function will be called very frequently, so it should be very fast.\n  ///\n  /// The user can recv the message if the user is the member of the collab object\n  async fn can_read_collab(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    oid: &Uuid,\n  ) -> Result<bool, AppError>;\n}\n"
  },
  {
    "path": "libs/access-control/src/entity.rs",
    "content": "#[derive(Debug, Clone)]\npub enum SubjectType {\n  User(i64),\n  Group(String),\n}\n\nimpl SubjectType {\n  pub fn policy_subject(&self) -> String {\n    match self {\n      SubjectType::User(i) => i.to_string(),\n      SubjectType::Group(s) => s.clone(),\n    }\n  }\n}\n\n/// Represents the object type that is stored in the access control policy.\n#[derive(Debug, Clone)]\npub enum ObjectType {\n  /// Stored as `workspace::<uuid>`\n  Workspace(String),\n  /// Stored as `collab::<uuid>`\n  Collab(String),\n}\n\nimpl ObjectType {\n  pub fn policy_object(&self) -> String {\n    match self {\n      ObjectType::Collab(s) => format!(\"collab::{}\", s),\n      ObjectType::Workspace(s) => format!(\"workspace::{}\", s),\n    }\n  }\n\n  pub fn object_id(&self) -> String {\n    match self {\n      ObjectType::Collab(s) => s.clone(),\n      ObjectType::Workspace(s) => s.clone(),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/lib.rs",
    "content": "pub mod act;\n#[cfg(feature = \"casbin\")]\npub mod casbin;\npub mod collab;\npub mod entity;\npub mod metrics;\npub mod noops;\nmod request;\npub mod workspace;\n"
  },
  {
    "path": "libs/access-control/src/metrics.rs",
    "content": "use prometheus_client::metrics::gauge::Gauge;\nuse std::sync::atomic::{AtomicI64, Ordering};\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse prometheus_client::registry::Registry;\nuse tokio::time::interval;\n\npub const ENFORCER_METRICS_TICK_INTERVAL: Duration = Duration::from_secs(120);\n\n#[derive(Clone)]\npub struct AccessControlMetrics {\n  load_all_policies: Gauge,\n  total_read_enforce_count: Gauge,\n  read_enforce_from_cache_count: Gauge,\n  policy_send_attempts: Gauge,\n  policy_send_failures: Gauge,\n  policy_channel_full_events: Gauge,\n}\n\nimpl AccessControlMetrics {\n  pub(crate) fn init() -> Self {\n    Self {\n      load_all_policies: Gauge::default(),\n      total_read_enforce_count: Gauge::default(),\n      read_enforce_from_cache_count: Gauge::default(),\n      policy_send_attempts: Gauge::default(),\n      policy_send_failures: Gauge::default(),\n      policy_channel_full_events: Gauge::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::init();\n    let realtime_registry = registry.sub_registry_with_prefix(\"ac\");\n    realtime_registry.register(\n      \"load_all_polices\",\n      \"load all polices when server start duration in milliseconds\",\n      metrics.load_all_policies.clone(),\n    );\n\n    realtime_registry.register(\n      \"total_read_enforce_count\",\n      \"total read enforce count\",\n      metrics.total_read_enforce_count.clone(),\n    );\n\n    realtime_registry.register(\n      \"read_enforce_from_cache_count\",\n      \"read enforce result from cache\",\n      metrics.read_enforce_from_cache_count.clone(),\n    );\n\n    realtime_registry.register(\n      \"policy_send_attempts\",\n      \"total policy channel send attempts\",\n      metrics.policy_send_attempts.clone(),\n    );\n\n    realtime_registry.register(\n      \"policy_send_failures\",\n      \"policy channel send failures (timeout or closed)\",\n      metrics.policy_send_failures.clone(),\n    );\n\n    realtime_registry.register(\n      \"policy_channel_full_events\",\n      \"times policy channel was full (would block)\",\n      metrics.policy_channel_full_events.clone(),\n    );\n\n    metrics\n  }\n\n  pub fn record_load_all_policies_in_ms(&self, millis: u64) {\n    self.load_all_policies.set(millis as i64);\n  }\n\n  pub fn record_enforce_count(&self, total: i64, from_cache: i64) {\n    self.total_read_enforce_count.set(total);\n    self.read_enforce_from_cache_count.set(from_cache);\n  }\n\n  pub fn record_policy_send_metrics(&self, attempts: i64, failures: i64, channel_full: i64) {\n    self.policy_send_attempts.set(attempts);\n    self.policy_send_failures.set(failures);\n    self.policy_channel_full_events.set(channel_full);\n  }\n}\n\n#[derive(Clone)]\npub(crate) struct MetricsCalState {\n  pub(crate) total_read_enforce_result: Arc<AtomicI64>,\n  pub(crate) read_enforce_result_from_cache: Arc<AtomicI64>,\n  pub(crate) policy_send_attempts: Arc<AtomicI64>,\n  pub(crate) policy_send_failures: Arc<AtomicI64>,\n  pub(crate) policy_channel_full_events: Arc<AtomicI64>,\n}\n\nimpl MetricsCalState {\n  pub(crate) fn new() -> Self {\n    Self {\n      total_read_enforce_result: Arc::new(Default::default()),\n      read_enforce_result_from_cache: Arc::new(Default::default()),\n      policy_send_attempts: Arc::new(Default::default()),\n      policy_send_failures: Arc::new(Default::default()),\n      policy_channel_full_events: Arc::new(Default::default()),\n    }\n  }\n}\n\n/// Collect and record metrics for access control\npub(crate) fn tick_metric(state: MetricsCalState, metrics: Arc<AccessControlMetrics>) {\n  tokio::spawn(async move {\n    let mut interval = interval(ENFORCER_METRICS_TICK_INTERVAL);\n    loop {\n      interval.tick().await;\n\n      metrics.record_enforce_count(\n        state.total_read_enforce_result.load(Ordering::Relaxed),\n        state.read_enforce_result_from_cache.load(Ordering::Relaxed),\n      );\n\n      metrics.record_policy_send_metrics(\n        state.policy_send_attempts.load(Ordering::Relaxed),\n        state.policy_send_failures.load(Ordering::Relaxed),\n        state.policy_channel_full_events.load(Ordering::Relaxed),\n      );\n    }\n  });\n}\n"
  },
  {
    "path": "libs/access-control/src/noops/collab.rs",
    "content": "use app_error::AppError;\nuse async_trait::async_trait;\nuse database_entity::dto::AFAccessLevel;\nuse uuid::Uuid;\n\nuse crate::{\n  act::Action,\n  collab::{CollabAccessControl, RealtimeAccessControl},\n};\n\n#[derive(Clone)]\npub struct CollabAccessControlImpl;\n\nimpl CollabAccessControlImpl {\n  pub fn new() -> Self {\n    Self {}\n  }\n}\n\nimpl Default for CollabAccessControlImpl {\n  fn default() -> Self {\n    Self::new()\n  }\n}\n\n#[async_trait]\nimpl CollabAccessControl for CollabAccessControlImpl {\n  async fn enforce_action(\n    &self,\n    _workspace_id: &Uuid,\n    _uid: &i64,\n    _oid: &Uuid,\n    _action: Action,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn enforce_access_level(\n    &self,\n    _workspace_id: &Uuid,\n    _uid: &i64,\n    _oid: &Uuid,\n    _access_level: AFAccessLevel,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn update_access_level_policy(\n    &self,\n    _uid: &i64,\n    _oid: &Uuid,\n    _level: AFAccessLevel,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn remove_access_level(&self, _uid: &i64, _oid: &Uuid) -> Result<(), AppError> {\n    Ok(())\n  }\n}\n\n#[derive(Clone)]\npub struct RealtimeCollabAccessControlImpl;\n\nimpl RealtimeCollabAccessControlImpl {\n  pub fn new() -> Self {\n    Self {}\n  }\n}\n\nimpl Default for RealtimeCollabAccessControlImpl {\n  fn default() -> Self {\n    Self::new()\n  }\n}\n\n#[async_trait]\nimpl RealtimeAccessControl for RealtimeCollabAccessControlImpl {\n  async fn can_write_collab(\n    &self,\n    _workspace_id: &Uuid,\n    _uid: &i64,\n    _oid: &Uuid,\n  ) -> Result<bool, AppError> {\n    Ok(true)\n  }\n\n  async fn can_read_collab(\n    &self,\n    _workspace_id: &Uuid,\n    _uid: &i64,\n    _oid: &Uuid,\n  ) -> Result<bool, AppError> {\n    Ok(true)\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/noops/mod.rs",
    "content": "pub mod collab;\npub mod workspace;\n"
  },
  {
    "path": "libs/access-control/src/noops/workspace.rs",
    "content": "use async_trait::async_trait;\nuse uuid::Uuid;\n\nuse crate::act::Action;\nuse crate::workspace::WorkspaceAccessControl;\nuse app_error::AppError;\nuse database_entity::dto::AFRole;\n\n#[derive(Clone)]\npub struct WorkspaceAccessControlImpl;\n\nimpl WorkspaceAccessControlImpl {\n  pub fn new() -> Self {\n    Self {}\n  }\n}\n\nimpl Default for WorkspaceAccessControlImpl {\n  fn default() -> Self {\n    Self::new()\n  }\n}\n\n#[async_trait]\nimpl WorkspaceAccessControl for WorkspaceAccessControlImpl {\n  async fn enforce_role_strong(\n    &self,\n    _uid: &i64,\n    _workspace_id: &Uuid,\n    _role: AFRole,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn enforce_role_weak(\n    &self,\n    _uid: &i64,\n    _workspace_id: &Uuid,\n    _role: AFRole,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn enforce_action(\n    &self,\n    _uid: &i64,\n    _workspace_id: &Uuid,\n    _action: Action,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn insert_role(\n    &self,\n    _uid: &i64,\n    _workspace_id: &Uuid,\n    _role: AFRole,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n\n  async fn remove_user_from_workspace(\n    &self,\n    _uid: &i64,\n    _workspace_id: &Uuid,\n  ) -> Result<(), AppError> {\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/request.rs",
    "content": "use crate::act::Acts;\nuse crate::entity::ObjectType;\n\npub struct PolicyRequest {\n  uid: i64,\n  object_type: ObjectType,\n  action_policy_string: String,\n}\n\nimpl PolicyRequest {\n  pub fn new<T>(uid: i64, object_type: ObjectType, action: T) -> Self\n  where\n    T: Acts,\n  {\n    Self {\n      uid,\n      object_type,\n      action_policy_string: action.to_enforce_act().to_string(),\n    }\n  }\n\n  pub fn to_policy(&self) -> Vec<String> {\n    vec![\n      self.uid.to_string(),\n      self.object_type.policy_object(),\n      self.action_policy_string.clone(),\n    ]\n  }\n}\n"
  },
  {
    "path": "libs/access-control/src/workspace.rs",
    "content": "use crate::act::Action;\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse database_entity::dto::AFRole;\nuse sqlx::types::Uuid;\n\n#[async_trait]\npub trait WorkspaceAccessControl: Send + Sync + 'static {\n  /// Check if the user has the role in the workspace.\n  /// Returns AppError::NotEnoughPermission if the user does not have the role.\n  async fn enforce_role_strong(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    role: AFRole,\n  ) -> Result<(), AppError>;\n\n  async fn enforce_role_weak(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    role: AFRole,\n  ) -> Result<(), AppError>;\n\n  /// Check if the user can perform action on the workspace.\n  /// Returns AppError::NotEnoughPermission if the user does not have the role.\n  async fn enforce_action(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n    action: Action,\n  ) -> Result<(), AppError>;\n\n  async fn insert_role(&self, uid: &i64, workspace_id: &Uuid, role: AFRole)\n    -> Result<(), AppError>;\n\n  async fn remove_user_from_workspace(\n    &self,\n    uid: &i64,\n    workspace_id: &Uuid,\n  ) -> Result<(), AppError>;\n}\n"
  },
  {
    "path": "libs/app-error/Cargo.toml",
    "content": "[package]\nname = \"app-error\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nthiserror = \"1.0.56\"\nserde_repr = \"0.1.18\"\nserde.workspace = true\nanyhow.workspace = true\nuuid = { workspace = true, features = [\"v4\"] }\nsqlx = { workspace = true, default-features = false, features = [\n  \"postgres\",\n  \"json\",\n], optional = true }\nvalidator = { workspace = true, optional = true }\nurl = { version = \"2.5.0\" }\nactix-web = { version = \"4.4.1\", optional = true }\nreqwest.workspace = true\nserde_json.workspace = true\ntokio = { workspace = true, optional = true }\nbincode = { version = \"1.3.3\", optional = true }\nappflowy-ai-client = { workspace = true, optional = true, features = [\"dto\"] }\nasync-openai = { workspace = true, optional = true }\ntokio-tungstenite = { workspace = true }\n\n[features]\ndefault = []\nsqlx_error = [\"sqlx\"]\nvalidation_error = [\"validator\"]\nactix_web_error = [\"actix-web\"]\ntokio_error = [\"tokio\"]\ngotrue_error = []\nbincode_error = [\"bincode\"]\nappflowy_ai_error = [\"appflowy-ai-client\", \"async-openai\"]\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\ngetrandom = { version = \"0.2\", features = [\"js\"] }\ntsify = \"0.4.5\"\nwasm-bindgen = \"0.2.84\"\n"
  },
  {
    "path": "libs/app-error/src/gotrue.rs",
    "content": "use reqwest::StatusCode;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::{Display, Formatter};\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum GoTrueError {\n  #[error(\"connect error:{0}\")]\n  Connect(String),\n\n  #[error(\"request timeout:{0}\")]\n  RequestTimeout(String),\n\n  #[error(\"invalid request:{0}\")]\n  InvalidRequest(String),\n\n  #[error(transparent)]\n  ClientError(#[from] GotrueClientError),\n\n  #[error(transparent)]\n  Internal(#[from] GoTrueErrorSerde),\n\n  #[error(\"{0}\")]\n  NotLoggedIn(String),\n\n  #[error(\"{0}\")]\n  Auth(String),\n\n  #[error(transparent)]\n  Unhandled(#[from] anyhow::Error),\n}\n\nimpl GoTrueError {\n  pub fn is_network_error(&self) -> bool {\n    matches!(\n      self,\n      GoTrueError::Connect(_) | GoTrueError::RequestTimeout(_)\n    )\n  }\n}\n\nimpl From<reqwest::Error> for GoTrueError {\n  fn from(value: reqwest::Error) -> Self {\n    #[cfg(not(target_arch = \"wasm32\"))]\n    if value.is_connect() {\n      return GoTrueError::Connect(value.to_string());\n    }\n\n    if value.is_timeout() {\n      return GoTrueError::RequestTimeout(value.to_string());\n    }\n\n    if value.is_request() {\n      return GoTrueError::InvalidRequest(value.to_string());\n    }\n\n    if let Some(status) = value.status() {\n      if status == StatusCode::UNAUTHORIZED {\n        return GoTrueError::Auth(value.to_string());\n      }\n    }\n\n    GoTrueError::Unhandled(value.into())\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug, Error)]\npub struct GoTrueErrorSerde {\n  pub code: i64,\n  pub msg: String,\n  pub error_id: Option<String>,\n}\n\nimpl Display for GoTrueErrorSerde {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"code: {}, msg:{}, error_id: {:?}\",\n      self.code, self.msg, self.error_id\n    ))\n  }\n}\n\n/// The gotrue error definition:\n/// https://github.com/supabase/auth/blob/cc07b4aa2ace75d9c8e46ae5107dbabadf944e87/internal/models/errors.go#L65\n/// Used to deserialize the response from the gotrue server\n#[derive(Serialize, Deserialize, Debug, Error)]\npub struct GotrueClientError {\n  pub error: Option<String>,\n  pub error_description: Option<String>,\n  pub msg: Option<String>,\n}\n\nimpl Display for GotrueClientError {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"error: {:?}, error_description: {:?}, msg: {:?}\",\n      self.error, self.error_description, self.msg\n    ))\n  }\n}\n"
  },
  {
    "path": "libs/app-error/src/lib.rs",
    "content": "#[cfg(feature = \"gotrue_error\")]\npub mod gotrue;\n\n#[cfg(feature = \"gotrue_error\")]\nuse crate::gotrue::GoTrueError;\n#[cfg(feature = \"appflowy_ai_error\")]\nuse appflowy_ai_client::error::AIError;\nuse reqwest::StatusCode;\nuse serde::Serialize;\nuse std::error::Error;\nuse std::string::FromUtf8Error;\nuse thiserror::Error;\nuse uuid::Uuid;\n\n#[derive(Debug, Error, Default)]\npub enum AppError {\n  #[error(\"Operation completed successfully.\")]\n  #[default]\n  Ok,\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n\n  #[error(\"An unhandled error occurred:{0}\")]\n  Unhandled(String),\n\n  #[error(\"Record not found:{0}\")]\n  RecordNotFound(String),\n\n  #[error(\"Record deleted:{0}\")]\n  RecordDeleted(String),\n\n  #[error(\"Record already exist:{0}\")]\n  RecordAlreadyExists(String),\n\n  #[error(\"Invalid email format:{0}\")]\n  InvalidEmail(String),\n\n  #[error(\"Invalid password:{0}\")]\n  InvalidPassword(String),\n\n  #[error(\"Invalid page data:{0}\")]\n  InvalidPageData(String),\n\n  #[error(\"{0}\")]\n  OAuthError(String),\n\n  #[error(\"{0}\")]\n  UserUnAuthorized(String),\n\n  #[error(\"{0}\")]\n  UserAlreadyRegistered(String),\n\n  #[error(\"Missing Payload:{0}\")]\n  MissingPayload(String),\n\n  #[error(\"Database error:{0}\")]\n  DBError(String),\n\n  #[error(\"Open Error:{0}\")]\n  OpenError(String),\n\n  #[error(\"Invalid request:{0}\")]\n  InvalidRequest(String),\n\n  #[error(\"Invalid OAuth Provider:{0}\")]\n  InvalidOAuthProvider(String),\n\n  #[error(\"Not Logged In:{0}\")]\n  NotLoggedIn(String),\n\n  #[error(\"User does not have permissions to execute this action\")]\n  NotEnoughPermissions,\n\n  #[error(\"s3 response error:{0}\")]\n  S3ResponseError(String),\n\n  #[error(\"Storage space not enough\")]\n  StorageSpaceNotEnough,\n\n  #[error(\"Payload too large:{0}\")]\n  PayloadTooLarge(String),\n\n  #[error(transparent)]\n  UuidError(#[from] uuid::Error),\n\n  #[error(transparent)]\n  IOError(#[from] std::io::Error),\n\n  #[cfg(feature = \"sqlx_error\")]\n  #[error(\"{0}\")]\n  SqlxError(String),\n\n  #[cfg(feature = \"sqlx_error\")]\n  #[error(\"{desc}: {err}\")]\n  SqlxArgEncodingError {\n    desc: String,\n    err: Box<dyn std::error::Error + 'static + Send + Sync>,\n  },\n\n  #[cfg(feature = \"validation_error\")]\n  #[error(transparent)]\n  ValidatorError(#[from] validator::ValidationErrors),\n\n  #[error(transparent)]\n  UrlError(#[from] url::ParseError),\n\n  #[error(transparent)]\n  SerdeError(#[from] serde_json::Error),\n\n  #[error(transparent)]\n  Utf8Error(#[from] FromUtf8Error),\n\n  #[error(\"{0}\")]\n  Connect(String),\n\n  #[error(\"{0}\")]\n  RequestTimeout(String),\n\n  #[cfg(feature = \"tokio_error\")]\n  #[error(transparent)]\n  TokioJoinError(#[from] tokio::task::JoinError),\n\n  #[cfg(feature = \"bincode_error\")]\n  #[error(transparent)]\n  BincodeError(#[from] bincode::Error),\n\n  #[error(\"{0}\")]\n  NoRequiredData(String),\n\n  #[error(\"{0}\")]\n  OverrideWithIncorrectData(String),\n\n  #[error(\"{0}\")]\n  PublishNamespaceAlreadyTaken(String),\n\n  #[error(\"{0}\")]\n  AIServiceUnavailable(String),\n\n  #[error(\"{0}\")]\n  StringLengthLimitReached(String),\n\n  #[error(\"{0}\")]\n  InvalidContentType(String),\n\n  #[error(\"{0}\")]\n  InvalidPublishedOutline(String),\n\n  #[error(\"{0}\")]\n  InvalidFolderView(String),\n\n  #[error(\"{0}\")]\n  NotInviteeOfWorkspaceInvitation(String),\n\n  #[error(\"{0}\")]\n  MissingView(String),\n\n  #[error(\"{0}\")]\n  TooManyImportTask(String),\n\n  #[error(\"There is existing access request for workspace {workspace_id} and view {view_id}\")]\n  AccessRequestAlreadyExists { workspace_id: Uuid, view_id: Uuid },\n\n  #[error(\"There is existing published view for workspace {workspace_id} with publish_name {publish_name}\")]\n  PublishNameAlreadyExists {\n    workspace_id: Uuid,\n    publish_name: String,\n  },\n\n  #[error(\"There is an invalid character in the publish name: {character}\")]\n  PublishNameInvalidCharacter { character: char },\n\n  #[error(\"The publish name is too long, given length: {given_length}, max length: {max_length}\")]\n  PublishNameTooLong {\n    given_length: usize,\n    max_length: usize,\n  },\n\n  #[error(\"There is an invalid character in the publish namespace: {character}\")]\n  CustomNamespaceInvalidCharacter { character: char },\n\n  #[error(\"{0}\")]\n  ServiceTemporaryUnavailable(String),\n\n  #[error(\"Decode update error: {0}\")]\n  DecodeUpdateError(String),\n\n  #[error(\"{0}\")]\n  ActionTimeout(String),\n\n  #[error(\"Apply update error:{0}\")]\n  ApplyUpdateError(String),\n\n  #[error(\"{0}\")]\n  InvalidBlock(String),\n\n  #[error(\"{0}\")]\n  FeatureNotAvailable(String),\n\n  #[error(\"unable to find invitation code\")]\n  InvalidInvitationCode,\n\n  #[error(\"{0} is already a member of the workspace\")]\n  InvalidGuest(String),\n\n  #[error(\"free plan workspace guest limit exceeded\")]\n  FreePlanGuestLimitExceeded,\n\n  #[error(\"paid plan workspace guest limit exceeded\")]\n  PaidPlanGuestLimitExceeded,\n\n  #[error(\"{0}\")]\n  RetryLater(anyhow::Error),\n}\n\nimpl AppError {\n  pub fn is_not_enough_permissions(&self) -> bool {\n    matches!(self, AppError::NotEnoughPermissions)\n  }\n\n  pub fn is_record_not_found(&self) -> bool {\n    matches!(self, AppError::RecordNotFound(_))\n  }\n\n  pub fn is_network_error(&self) -> bool {\n    matches!(self, AppError::Connect(_) | AppError::RequestTimeout(_))\n  }\n\n  pub fn is_unauthorized(&self) -> bool {\n    matches!(self, AppError::UserUnAuthorized(_))\n  }\n\n  pub fn code(&self) -> ErrorCode {\n    match self {\n      AppError::Ok => ErrorCode::Ok,\n      AppError::Unhandled(_) => ErrorCode::Unhandled,\n      AppError::RecordNotFound(_) => ErrorCode::RecordNotFound,\n      AppError::RecordAlreadyExists(_) => ErrorCode::RecordAlreadyExists,\n      AppError::InvalidEmail(_) => ErrorCode::InvalidEmail,\n      AppError::InvalidPassword(_) => ErrorCode::InvalidPassword,\n      AppError::OAuthError(_) => ErrorCode::OAuthError,\n      AppError::UserUnAuthorized(_) => ErrorCode::UserUnAuthorized,\n      AppError::UserAlreadyRegistered(_) => ErrorCode::RecordAlreadyExists,\n      AppError::MissingPayload(_) => ErrorCode::MissingPayload,\n      AppError::DBError(_) => ErrorCode::DBError,\n      AppError::OpenError(_) => ErrorCode::OpenError,\n      AppError::InvalidOAuthProvider(_) => ErrorCode::InvalidOAuthProvider,\n      AppError::InvalidRequest(_) => ErrorCode::InvalidRequest,\n      AppError::NotLoggedIn(_) => ErrorCode::NotLoggedIn,\n      AppError::NotEnoughPermissions => ErrorCode::NotEnoughPermissions,\n      AppError::StorageSpaceNotEnough => ErrorCode::StorageSpaceNotEnough,\n      AppError::PayloadTooLarge(_) => ErrorCode::PayloadTooLarge,\n      AppError::Internal(_) => ErrorCode::Internal,\n      AppError::UuidError(_) => ErrorCode::UuidError,\n      AppError::IOError(_) => ErrorCode::IOError,\n      #[cfg(feature = \"sqlx_error\")]\n      AppError::SqlxError(_) => ErrorCode::SqlxError,\n      #[cfg(feature = \"sqlx_error\")]\n      AppError::SqlxArgEncodingError { .. } => ErrorCode::SqlxArgEncodingError,\n      #[cfg(feature = \"validation_error\")]\n      AppError::ValidatorError(_) => ErrorCode::InvalidRequest,\n      AppError::S3ResponseError(_) => ErrorCode::S3ResponseError,\n      AppError::UrlError(_) => ErrorCode::InvalidUrl,\n      AppError::SerdeError(_) => ErrorCode::SerdeError,\n      AppError::Connect(_) => ErrorCode::NetworkError,\n      AppError::RequestTimeout(_) => ErrorCode::RequestTimeout,\n      #[cfg(feature = \"tokio_error\")]\n      AppError::TokioJoinError(_) => ErrorCode::Internal,\n      #[cfg(feature = \"bincode_error\")]\n      AppError::BincodeError(_) => ErrorCode::Internal,\n      AppError::NoRequiredData(_) => ErrorCode::NoRequiredData,\n      AppError::OverrideWithIncorrectData(_) => ErrorCode::OverrideWithIncorrectData,\n      AppError::Utf8Error(_) => ErrorCode::Internal,\n      AppError::PublishNamespaceAlreadyTaken(_) => ErrorCode::PublishNamespaceAlreadyTaken,\n      AppError::AIServiceUnavailable(_) => ErrorCode::AIServiceUnavailable,\n      AppError::StringLengthLimitReached(_) => ErrorCode::StringLengthLimitReached,\n      AppError::InvalidContentType(_) => ErrorCode::InvalidContentType,\n      AppError::InvalidPublishedOutline(_) => ErrorCode::InvalidPublishedOutline,\n      AppError::InvalidFolderView(_) => ErrorCode::InvalidFolderView,\n      AppError::InvalidPageData(_) => ErrorCode::InvalidPageData,\n      AppError::NotInviteeOfWorkspaceInvitation(_) => ErrorCode::NotInviteeOfWorkspaceInvitation,\n      AppError::MissingView(_) => ErrorCode::MissingView,\n      AppError::AccessRequestAlreadyExists { .. } => ErrorCode::AccessRequestAlreadyExists,\n      AppError::TooManyImportTask(_) => ErrorCode::TooManyImportTask,\n      AppError::PublishNameAlreadyExists { .. } => ErrorCode::PublishNameAlreadyExists,\n      AppError::PublishNameInvalidCharacter { .. } => ErrorCode::PublishNameInvalidCharacter,\n      AppError::PublishNameTooLong { .. } => ErrorCode::PublishNameTooLong,\n      AppError::CustomNamespaceInvalidCharacter { .. } => {\n        ErrorCode::CustomNamespaceInvalidCharacter\n      },\n      AppError::ServiceTemporaryUnavailable(_) => ErrorCode::ServiceTemporaryUnavailable,\n      AppError::DecodeUpdateError(_) => ErrorCode::DecodeUpdateError,\n      AppError::ApplyUpdateError(_) => ErrorCode::ApplyUpdateError,\n      AppError::ActionTimeout(_) => ErrorCode::ActionTimeout,\n      AppError::InvalidBlock(_) => ErrorCode::InvalidBlock,\n      AppError::FeatureNotAvailable(_) => ErrorCode::FeatureNotAvailable,\n      AppError::InvalidInvitationCode => ErrorCode::InvalidInvitationCode,\n      AppError::InvalidGuest(_) => ErrorCode::InvalidGuest,\n      AppError::FreePlanGuestLimitExceeded => ErrorCode::FreePlanGuestLimitExceeded,\n      AppError::PaidPlanGuestLimitExceeded => ErrorCode::PaidPlanGuestLimitExceeded,\n      AppError::RecordDeleted(_) => ErrorCode::RecordDeleted,\n      AppError::RetryLater(_) => ErrorCode::RetryLater,\n    }\n  }\n}\n\nimpl From<reqwest::Error> for AppError {\n  fn from(error: reqwest::Error) -> Self {\n    #[cfg(not(target_arch = \"wasm32\"))]\n    if error.is_connect() {\n      return AppError::Connect(error.to_string());\n    }\n\n    if error.is_timeout() {\n      return AppError::RequestTimeout(error.to_string());\n    }\n\n    if error.is_request() {\n      return AppError::ServiceTemporaryUnavailable(error.to_string());\n    }\n\n    if let Some(cause) = error.source() {\n      if cause\n        .to_string()\n        .contains(\"connection closed before message completed\")\n      {\n        return AppError::ServiceTemporaryUnavailable(error.to_string());\n      }\n    }\n\n    // Handle request-related errors\n    if let Some(status_code) = error.status() {\n      if error.is_request() {\n        match status_code {\n          StatusCode::PAYLOAD_TOO_LARGE => {\n            return AppError::PayloadTooLarge(error.to_string());\n          },\n          status_code if status_code.is_server_error() => {\n            return AppError::ServiceTemporaryUnavailable(error.to_string());\n          },\n          _ => {\n            return AppError::InvalidRequest(error.to_string());\n          },\n        }\n      }\n    }\n\n    AppError::Internal(error.into())\n  }\n}\n\n#[cfg(feature = \"sqlx_error\")]\nimpl From<sqlx::Error> for AppError {\n  fn from(value: sqlx::Error) -> Self {\n    let msg = value.to_string();\n    match value {\n      sqlx::Error::RowNotFound => {\n        AppError::RecordNotFound(format!(\"Record not exist in db. {})\", msg))\n      },\n      sqlx::Error::PoolTimedOut => AppError::ActionTimeout(value.to_string()),\n      _ => AppError::SqlxError(msg),\n    }\n  }\n}\n\n#[cfg(feature = \"gotrue_error\")]\nimpl From<crate::gotrue::GoTrueError> for AppError {\n  fn from(err: crate::gotrue::GoTrueError) -> Self {\n    match err {\n      GoTrueError::Connect(msg) => AppError::Connect(msg),\n      GoTrueError::RequestTimeout(msg) => AppError::RequestTimeout(msg),\n      GoTrueError::InvalidRequest(msg) => AppError::InvalidRequest(msg),\n      GoTrueError::ClientError(err) => AppError::OAuthError(err.to_string()),\n      GoTrueError::Auth(err) => AppError::UserUnAuthorized(err),\n      GoTrueError::Internal(err) => match (err.code, err.msg.as_str()) {\n        (400, m) if m.starts_with(\"oauth error\") => AppError::OAuthError(err.msg),\n        (400, m) if m.starts_with(\"User already registered\") => {\n          AppError::UserAlreadyRegistered(err.msg)\n        },\n        (401, _) => AppError::UserUnAuthorized(format!(\"{}:{}\", err.code, err.msg)),\n        (422, _) => AppError::InvalidRequest(err.msg),\n        _ => AppError::OAuthError(err.msg),\n      },\n      GoTrueError::Unhandled(err) => AppError::Internal(err),\n      GoTrueError::NotLoggedIn(msg) => AppError::NotLoggedIn(msg),\n    }\n  }\n}\n\nimpl From<String> for AppError {\n  fn from(err: String) -> Self {\n    AppError::Unhandled(err)\n  }\n}\n\n#[cfg_attr(target_arch = \"wasm32\", derive(tsify::Tsify))]\n#[derive(\n  Eq,\n  PartialEq,\n  Copy,\n  Debug,\n  Clone,\n  serde_repr::Serialize_repr,\n  serde_repr::Deserialize_repr,\n  Default,\n)]\n#[repr(i32)]\npub enum ErrorCode {\n  #[default]\n  Ok = 0,\n  Unhandled = -1,\n  RecordNotFound = -2,\n  RecordAlreadyExists = -3,\n  RecordDeleted = -4,\n  RetryLater = -5,\n  InvalidEmail = 1001,\n  InvalidPassword = 1002,\n  OAuthError = 1003,\n  MissingPayload = 1004,\n  DBError = 1005,\n  OpenError = 1006,\n  InvalidUrl = 1007,\n  InvalidRequest = 1008,\n  InvalidOAuthProvider = 1009,\n  NotLoggedIn = 1011,\n  NotEnoughPermissions = 1012,\n  StorageSpaceNotEnough = 1015,\n  PayloadTooLarge = 1016,\n  Internal = 1017,\n  UuidError = 1018,\n  IOError = 1019,\n  #[cfg(feature = \"sqlx_error\")]\n  SqlxError = 1020,\n  S3ResponseError = 1021,\n  SerdeError = 1022,\n  NetworkError = 1023,\n  UserUnAuthorized = 1024,\n  NoRequiredData = 1025,\n  WorkspaceLimitExceeded = 1026,\n  WorkspaceMemberLimitExceeded = 1027,\n  FileStorageLimitExceeded = 1028,\n  OverrideWithIncorrectData = 1029,\n  PublishNamespaceNotSet = 1030,\n  PublishNamespaceAlreadyTaken = 1031,\n  AIServiceUnavailable = 1032,\n  AIResponseLimitExceeded = 1033,\n  StringLengthLimitReached = 1034,\n  #[cfg(feature = \"sqlx_error\")]\n  SqlxArgEncodingError = 1035,\n  InvalidContentType = 1036,\n  SingleUploadLimitExceeded = 1037,\n  AppleRevokeTokenError = 1038,\n  InvalidPublishedOutline = 1039,\n  InvalidFolderView = 1040,\n  NotInviteeOfWorkspaceInvitation = 1041,\n  MissingView = 1042,\n  AccessRequestAlreadyExists = 1043,\n  CustomNamespaceDisabled = 1044,\n  CustomNamespaceDisallowed = 1045,\n  TooManyImportTask = 1046,\n  CustomNamespaceTooShort = 1047,\n  CustomNamespaceTooLong = 1048,\n  CustomNamespaceReserved = 1049,\n  PublishNameAlreadyExists = 1050,\n  PublishNameInvalidCharacter = 1051,\n  PublishNameTooLong = 1052,\n  CustomNamespaceInvalidCharacter = 1053,\n  ServiceTemporaryUnavailable = 1054,\n  DecodeUpdateError = 1055,\n  ApplyUpdateError = 1056,\n  ActionTimeout = 1057,\n  AIImageResponseLimitExceeded = 1058,\n  MailerError = 1059,\n  LicenseError = 1060,\n  AIMaxRequired = 1061,\n  InvalidPageData = 1062,\n  MemberNotFound = 1063,\n  InvalidBlock = 1064,\n  RequestTimeout = 1065,\n  AIResponseError = 1066,\n  FeatureNotAvailable = 1067,\n  InvalidInvitationCode = 1068,\n  InvalidGuest = 1069,\n  FreePlanGuestLimitExceeded = 1070,\n  PaidPlanGuestLimitExceeded = 1071,\n}\n\nimpl ErrorCode {\n  pub fn value(&self) -> i32 {\n    *self as i32\n  }\n}\n\n#[derive(Serialize)]\nstruct AppErrorSerde {\n  code: ErrorCode,\n  message: String,\n}\n\nimpl From<&AppError> for AppErrorSerde {\n  fn from(value: &AppError) -> Self {\n    Self {\n      code: value.code(),\n      message: value.to_string(),\n    }\n  }\n}\n\n#[cfg(feature = \"actix_web_error\")]\nimpl actix_web::error::ResponseError for AppError {\n  fn status_code(&self) -> actix_web::http::StatusCode {\n    actix_web::http::StatusCode::OK\n  }\n\n  fn error_response(&self) -> actix_web::HttpResponse {\n    actix_web::HttpResponse::Ok().json(AppErrorSerde::from(self))\n  }\n}\n\n#[cfg(feature = \"appflowy_ai_error\")]\nimpl From<AIError> for AppError {\n  fn from(err: AIError) -> Self {\n    match err {\n      AIError::Internal(err) => AppError::Internal(err),\n      AIError::RequestTimeout(err) => AppError::RequestTimeout(err),\n      AIError::PayloadTooLarge(err) => AppError::PayloadTooLarge(err),\n      AIError::InvalidRequest(err) => AppError::InvalidRequest(err),\n      AIError::SerdeError(err) => AppError::SerdeError(err),\n      AIError::ServiceUnavailable(err) => AppError::AIServiceUnavailable(err),\n    }\n  }\n}\n\n#[cfg(feature = \"appflowy_ai_error\")]\nimpl From<async_openai::error::OpenAIError> for AppError {\n  fn from(err: async_openai::error::OpenAIError) -> Self {\n    match &err {\n      async_openai::error::OpenAIError::Reqwest(e) => AppError::InvalidRequest(e.to_string()),\n      async_openai::error::OpenAIError::ApiError(e) => AppError::InvalidRequest(e.to_string()),\n      async_openai::error::OpenAIError::InvalidArgument(e) => {\n        AppError::InvalidRequest(e.to_string())\n      },\n      _ => AppError::Internal(err.into()),\n    }\n  }\n}\n\nuse tokio_tungstenite::tungstenite::Error as TungsteniteError;\n\nimpl From<TungsteniteError> for AppError {\n  fn from(err: TungsteniteError) -> Self {\n    match &err {\n      TungsteniteError::Http(resp) => {\n        let status = resp.status();\n        if status == StatusCode::UNAUTHORIZED.as_u16() || status == StatusCode::NOT_FOUND.as_u16() {\n          AppError::UserUnAuthorized(\"Unauthorized websocket connection\".to_string())\n        } else {\n          AppError::Internal(err.into())\n        }\n      },\n      _ => AppError::Internal(err.into()),\n    }\n  }\n}\n\nimpl From<tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue> for AppError {\n  fn from(err: tokio_tungstenite::tungstenite::http::header::InvalidHeaderValue) -> Self {\n    AppError::InvalidRequest(err.to_string())\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/Cargo.toml",
    "content": "[package]\nname = \"appflowy-ai-client\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nreqwest = { workspace = true, features = [\n  \"json\",\n  \"rustls-tls\",\n  \"cookies\",\n  \"stream\",\n], optional = true }\nserde = { version = \"1.0.199\", features = [\"derive\"], optional = true }\nserde_json = { version = \"1.0\", optional = true }\nthiserror = \"1.0.58\"\nanyhow.workspace = true\ntracing = { version = \"0.1\", optional = true }\nserde_repr = { version = \"0.1\", optional = true }\nfutures = \"0.3.30\"\nbytes.workspace = true\nureq = { version = \"2.12.1\", optional = true, features = [\"json\"] }\nuuid = { workspace = true, features = [\"serde\"] }\n\n[dev-dependencies]\nappflowy-ai-client = { path = \".\", features = [\"dto\", \"client-api\"] }\ntokio = { version = \"1.37.0\", features = [\"macros\", \"test-util\"] }\ntracing-subscriber = { version = \"0.3.18\", features = [\n  \"registry\",\n  \"env-filter\",\n  \"ansi\",\n  \"json\",\n] }\nuuid = { workspace = true, features = [\"v4\"] }\ninfra.workspace = true\n\n[features]\ndefault = [\"client-api\"]\nclient-api = [\n  \"dto\",\n  \"reqwest\",\n  \"serde\",\n  \"serde_json\",\n  \"tracing\",\n  \"serde_repr\",\n  \"infra/request_util\", \"ureq\"\n]\ndto = [\"serde\", \"serde_json\", \"serde_repr\"]\n"
  },
  {
    "path": "libs/appflowy-ai-client/src/client.rs",
    "content": "use crate::dto::{\n  CalculateSimilarityParams, ChatAnswer, ChatQuestion, CompleteTextParams, CreateChatContext,\n  Document, LocalAIConfig, MessageData, ModelList, QuestionMetadata, RepeatedLocalAIPackage,\n  RepeatedRelatedQuestion, ResponseFormat, SearchDocumentsRequest, SimilarityResponse,\n  SummarizeRowResponse, TranslateRowData, TranslateRowResponse,\n};\nuse crate::error::AIError;\n\nuse bytes::Bytes;\nuse futures::{Stream, StreamExt};\nuse reqwest;\nuse reqwest::{Method, RequestBuilder, StatusCode};\nuse serde::de::DeserializeOwned;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse std::borrow::Cow;\nuse std::time::Duration;\nuse tracing::{info, trace};\n\nconst AI_MODEL_HEADER_KEY: &str = \"ai-model\";\n\n#[derive(Clone, Debug)]\npub struct AppFlowyAIClient {\n  async_client: reqwest::Client,\n  url: String,\n}\n\nimpl AppFlowyAIClient {\n  pub fn new(url: &str) -> Self {\n    info!(\"Creating AppFlowyAIClient with url: {}\", url);\n    let url = url.to_string();\n    let async_client = reqwest::Client::new();\n    Self { async_client, url }\n  }\n\n  pub async fn health_check(&self) -> Result<(), AIError> {\n    let url = format!(\"{}/health\", self.url);\n    let resp = self.async_http_client(Method::GET, &url)?.send().await?;\n    let text = resp.text().await?;\n    info!(\"health response: {:?}\", text);\n    Ok(())\n  }\n\n  pub async fn stream_completion_text(\n    &self,\n    params: CompleteTextParams,\n    model: &str,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    if params.text.is_empty() {\n      return Err(AIError::InvalidRequest(\"Empty text\".to_string()));\n    }\n\n    let url = format!(\"{}/completion/stream\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(&params)\n      .send()\n      .await?;\n    AIResponse::<()>::stream_response(resp).await\n  }\n\n  pub async fn stream_completion_v2(\n    &self,\n    params: CompleteTextParams,\n    model: &str,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    if params.text.is_empty() {\n      return Err(AIError::InvalidRequest(\"Empty text\".to_string()));\n    }\n\n    let url = format!(\"{}/v2/completion/stream\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(&params)\n      .send()\n      .await?;\n    AIResponse::<()>::stream_response(resp).await\n  }\n\n  pub async fn summarize_row(\n    &self,\n    params: &Map<String, Value>,\n    model: &str,\n  ) -> Result<SummarizeRowResponse, AIError> {\n    if params.is_empty() {\n      return Err(AIError::InvalidRequest(\"Empty content\".to_string()));\n    }\n\n    let url = format!(\"{}/summarize_row\", self.url);\n    trace!(\"summarize_row url: {}\", url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(params)\n      .send()\n      .await?;\n    AIResponse::<SummarizeRowResponse>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn translate_row(\n    &self,\n    data: TranslateRowData,\n    model: &str,\n  ) -> Result<TranslateRowResponse, AIError> {\n    let url = format!(\"{}/translate_row\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(&data)\n      .send()\n      .await?;\n    AIResponse::<TranslateRowResponse>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn search_documents(\n    &self,\n    request: &SearchDocumentsRequest,\n  ) -> Result<Vec<Document>, AIError> {\n    let url = format!(\"{}/search\", self.url);\n    let resp = self\n      .async_http_client(Method::GET, &url)?\n      .query(&request)\n      .send()\n      .await?;\n    AIResponse::<Vec<Document>>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn create_chat_text_context(&self, context: CreateChatContext) -> Result<(), AIError> {\n    let url = format!(\"{}/chat/context/text\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .json(&context)\n      .send()\n      .await?;\n    let _ = AIResponse::<()>::from_reqwest_response(resp).await?;\n    Ok(())\n  }\n\n  pub async fn send_question(\n    &self,\n    workspace_id: &str,\n    chat_id: &str,\n    question_id: i64,\n    content: &str,\n    model: &str,\n    metadata: Option<Value>,\n  ) -> Result<ChatAnswer, AIError> {\n    let json = ChatQuestion {\n      chat_id: chat_id.to_string(),\n      data: MessageData {\n        content: content.to_string(),\n        metadata,\n        message_id: Some(question_id.to_string()),\n      },\n      format: Default::default(),\n      metadata: QuestionMetadata {\n        workspace_id: workspace_id.to_string(),\n        rag_ids: vec![],\n      },\n    };\n    let url = format!(\"{}/chat/message\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(&json)\n      .send()\n      .await?;\n    AIResponse::<ChatAnswer>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn stream_question(\n    &self,\n    workspace_id: String,\n    chat_id: &str,\n    content: &str,\n    metadata: Option<Value>,\n    rag_ids: Vec<String>,\n    model: &str,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    let json = ChatQuestion {\n      chat_id: chat_id.to_string(),\n      data: MessageData {\n        content: content.to_string(),\n        metadata,\n        message_id: None,\n      },\n      format: Default::default(),\n      metadata: QuestionMetadata {\n        workspace_id,\n        rag_ids,\n      },\n    };\n    let url = format!(\"{}/chat/message/stream\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .timeout(Duration::from_secs(30))\n      .json(&json)\n      .send()\n      .await?;\n    AIResponse::<()>::stream_response(resp).await\n  }\n\n  #[allow(clippy::too_many_arguments)]\n  pub async fn stream_question_v2(\n    &self,\n    workspace_id: String,\n    chat_id: &str,\n    question_id: i64,\n    content: &str,\n    metadata: Option<Value>,\n    rag_ids: Vec<String>,\n    model: &str,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    let json = ChatQuestion {\n      chat_id: chat_id.to_string(),\n      data: MessageData {\n        content: content.to_string(),\n        metadata,\n        message_id: Some(question_id.to_string()),\n      },\n      format: ResponseFormat::default(),\n      metadata: QuestionMetadata {\n        workspace_id,\n        rag_ids,\n      },\n    };\n    self.stream_question_v3(model, json, Some(30)).await\n  }\n\n  pub async fn stream_question_v3(\n    &self,\n    model: &str,\n    question: ChatQuestion,\n    timeout_secs: Option<u64>,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    let url = format!(\"{}/v2/chat/message/stream\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .json(&question)\n      .timeout(Duration::from_secs(timeout_secs.unwrap_or(30)))\n      .send()\n      .await?;\n    AIResponse::<()>::stream_response(resp).await\n  }\n\n  pub async fn get_related_question(\n    &self,\n    chat_id: &str,\n    message_id: &i64,\n    model: &str,\n  ) -> Result<RepeatedRelatedQuestion, AIError> {\n    let url = format!(\"{}/chat/{chat_id}/{message_id}/related_question\", self.url);\n    let resp = self\n      .async_http_client(Method::GET, &url)?\n      .header(AI_MODEL_HEADER_KEY, model)\n      .timeout(Duration::from_secs(30))\n      .send()\n      .await?;\n    AIResponse::<RepeatedRelatedQuestion>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn regenerate_image(&self, source_metadata: Value) -> Result<(), AIError> {\n    let url = format!(\"{}/chat/image/regenerate\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .json(&source_metadata)\n      .timeout(Duration::from_secs(30))\n      .send()\n      .await?;\n    AIResponse::<()>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn get_local_ai_package(\n    &self,\n    platform: &str,\n  ) -> Result<RepeatedLocalAIPackage, AIError> {\n    let url = format!(\"{}/local_ai/plugin?platform={platform}\", self.url);\n    let resp = self.async_http_client(Method::GET, &url)?.send().await?;\n    AIResponse::<RepeatedLocalAIPackage>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn get_local_ai_config(\n    &self,\n    platform: &str,\n    app_version: Option<String>,\n  ) -> Result<LocalAIConfig, AIError> {\n    // Start with the base URL including the platform parameter\n    let mut url = format!(\"{}/local_ai/config?platform={}\", self.url, platform);\n\n    // If app_version is provided, append it as a query parameter\n    if let Some(version) = app_version {\n      url = format!(\"{}&app_version={}\", url, version);\n    }\n\n    let resp = self.async_http_client(Method::GET, &url)?.send().await?;\n    AIResponse::<LocalAIConfig>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn get_model_list(&self) -> Result<ModelList, AIError> {\n    let url = format!(\"{}/model/list\", self.url);\n\n    let resp = self.async_http_client(Method::GET, &url)?.send().await?;\n    AIResponse::<ModelList>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  pub async fn calculate_similarity(\n    &self,\n    params: CalculateSimilarityParams,\n  ) -> Result<SimilarityResponse, AIError> {\n    let url = format!(\"{}/similarity\", self.url);\n    let resp = self\n      .async_http_client(Method::POST, &url)?\n      .json(&params)\n      .send()\n      .await?;\n    AIResponse::<SimilarityResponse>::from_reqwest_response(resp)\n      .await?\n      .into_data()\n  }\n\n  fn async_http_client(&self, method: Method, url: &str) -> Result<RequestBuilder, AIError> {\n    let request_builder = self.async_client.request(method, url);\n    Ok(request_builder)\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AIResponse<T> {\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub data: Option<T>,\n\n  #[serde(default)]\n  pub message: Cow<'static, str>,\n}\n\nimpl<T> AIResponse<T>\nwhere\n  T: DeserializeOwned + 'static,\n{\n  pub async fn from_reqwest_response(resp: reqwest::Response) -> Result<Self, anyhow::Error> {\n    let status_code = resp.status();\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      anyhow::bail!(\"error code: {}, {}\", status_code, body)\n    }\n\n    let bytes = resp.bytes().await?;\n    let resp = serde_json::from_slice(&bytes)?;\n    Ok(resp)\n  }\n\n  pub fn from_ur_response(resp: ureq::Response) -> Result<Self, anyhow::Error> {\n    let status_code = resp.status();\n    if status_code != 200 {\n      let body = resp.into_string()?;\n      anyhow::bail!(\"error code: {}, {}\", status_code, body)\n    }\n\n    let resp = resp.into_json()?;\n    Ok(resp)\n  }\n\n  pub fn into_data(self) -> Result<T, AIError> {\n    match self.data {\n      None => Err(AIError::InvalidRequest(\"Empty payload\".to_string())),\n      Some(data) => Ok(data),\n    }\n  }\n\n  pub async fn stream_response(\n    resp: reqwest::Response,\n  ) -> Result<impl Stream<Item = Result<Bytes, AIError>>, AIError> {\n    let status_code = resp.status();\n    if status_code.is_server_error() {\n      let body = resp.text().await?;\n      return Err(AIError::ServiceUnavailable(body));\n    }\n\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      return Err(AIError::InvalidRequest(body));\n    }\n    let stream = resp\n      .bytes_stream()\n      .map(|item| item.map_err(|err| AIError::Internal(err.into())));\n    Ok(stream)\n  }\n}\nimpl From<reqwest::Error> for AIError {\n  fn from(error: reqwest::Error) -> Self {\n    if error.is_connect() {\n      return AIError::ServiceUnavailable(error.to_string());\n    }\n\n    if error.is_timeout() {\n      return AIError::RequestTimeout(error.to_string());\n    }\n\n    // Handle request-related errors\n    if let Some(status_code) = error.status() {\n      if error.is_request() {\n        match status_code {\n          StatusCode::PAYLOAD_TOO_LARGE => {\n            return AIError::PayloadTooLarge(error.to_string());\n          },\n          status_code if status_code.is_server_error() => {\n            return AIError::ServiceUnavailable(error.to_string());\n          },\n          _ => {\n            return AIError::InvalidRequest(format!(\"{:?}\", error));\n          },\n        }\n      }\n    }\n    AIError::Internal(error.into())\n  }\n}\n\npub async fn collect_stream_text(stream: impl Stream<Item = Result<Bytes, AIError>>) -> String {\n  let stream = stream.map(|item| {\n    item.map(|bytes| {\n      String::from_utf8(bytes.to_vec())\n        .map(|s| s.replace('\\n', \"\"))\n        .unwrap()\n    })\n  });\n\n  let lines: Vec<String> = stream.map(|message| message.unwrap()).collect().await;\n  lines.join(\"\")\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/src/dto.rs",
    "content": "use serde::{Deserialize, Serialize, Serializer};\nuse serde_json::json;\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse std::collections::HashMap;\nuse std::fmt::{Display, Formatter};\nuse uuid::Uuid;\n\npub const STREAM_METADATA_KEY: &str = \"0\";\npub const STREAM_ANSWER_KEY: &str = \"1\";\npub const STREAM_IMAGE_KEY: &str = \"2\";\npub const STREAM_KEEP_ALIVE_KEY: &str = \"3\";\npub const STREAM_COMMENT_KEY: &str = \"4\";\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SummarizeRowResponse {\n  pub text: String,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ChatQuestionQuery {\n  pub chat_id: String,\n  pub question_id: i64,\n  pub format: ResponseFormat,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ChatQuestion {\n  pub chat_id: String,\n  pub data: MessageData,\n  #[serde(default)]\n  pub format: ResponseFormat,\n  pub metadata: QuestionMetadata,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct QuestionMetadata {\n  pub workspace_id: String,\n  pub rag_ids: Vec<String>,\n}\n\n#[derive(Clone, Default, Debug, Serialize, Deserialize)]\npub struct ResponseFormat {\n  pub output_layout: OutputLayout,\n  pub output_content: OutputContent,\n  pub output_content_metadata: Option<OutputContentMetadata>,\n}\n\nimpl ResponseFormat {\n  pub fn new() -> Self {\n    Self::default()\n  }\n}\n\n#[derive(Clone, Debug, Default, Serialize_repr, Deserialize_repr, Eq, PartialEq)]\n#[repr(u8)]\npub enum OutputLayout {\n  Paragraph = 0,\n  BulletList = 1,\n  NumberedList = 2,\n  SimpleTable = 3,\n  #[default]\n  Flex = 4,\n}\n\n#[derive(Clone, Debug, Default, Serialize_repr, Deserialize_repr, Eq, PartialEq)]\n#[repr(u8)]\npub enum OutputContent {\n  #[default]\n  TEXT = 0,\n  IMAGE = 1,\n  RichTextImage = 2,\n}\n\nimpl OutputContent {\n  pub fn is_image(&self) -> bool {\n    *self == OutputContent::IMAGE || *self == OutputContent::RichTextImage\n  }\n}\n\n#[derive(Clone, Default, Debug, Serialize, Deserialize)]\npub struct OutputContentMetadata {\n  /// Custom prompt for image generation.\n  #[serde(default, skip_serializing_if = \"Option::is_none\")]\n  pub custom_image_prompt: Option<String>,\n\n  /// The image model to use for generation (default: \"dall-e-3\").\n  #[serde(default = \"default_image_model\")]\n  pub image_model: String,\n\n  /// Size of the image (default: \"256x256\").\n  #[serde(\n    default = \"default_image_size\",\n    skip_serializing_if = \"Option::is_none\"\n  )]\n  pub size: Option<String>,\n\n  /// Quality of the image (default: \"standard\").\n  #[serde(\n    default = \"default_image_quality\",\n    skip_serializing_if = \"Option::is_none\"\n  )]\n  pub quality: Option<String>,\n}\n\n// Default values for the fields\nfn default_image_model() -> String {\n  \"dall-e-3\".to_string()\n}\n\nfn default_image_size() -> Option<String> {\n  Some(\"256x256\".to_string())\n}\n\nfn default_image_quality() -> Option<String> {\n  Some(\"standard\".to_string())\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct MessageData {\n  pub content: String,\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<serde_json::Value>,\n  #[serde(default)]\n  pub message_id: Option<String>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ChatAnswer {\n  pub content: String,\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<serde_json::Value>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct RepeatedRelatedQuestion {\n  pub message_id: i64,\n  pub items: Vec<RelatedQuestion>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct RelatedQuestion {\n  pub content: String,\n\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<serde_json::Value>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CompleteTextResponse {\n  pub text: String,\n}\n\n#[derive(Clone, Debug, Serialize_repr, Deserialize_repr, Eq, PartialEq, Hash)]\n#[repr(u8)]\npub enum CompletionType {\n  ImproveWriting = 1,\n  SpellingAndGrammar = 2,\n  MakeShorter = 3,\n  MakeLonger = 4,\n  ContinueWriting = 5,\n  Explain = 6,\n  AskAI = 7,\n  CustomPrompt = 8,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct SearchDocumentsRequest {\n  #[serde(serialize_with = \"serialize_workspaces\")]\n  pub workspaces: Vec<String>,\n  pub query: String,\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub result_count: Option<u32>,\n}\n\n#[allow(clippy::ptr_arg)]\nfn serialize_workspaces<S>(workspaces: &Vec<String>, serializer: S) -> Result<S::Ok, S::Error>\nwhere\n  S: Serializer,\n{\n  let workspaces = workspaces.join(\",\");\n  serializer.serialize_str(&workspaces)\n}\n\n#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]\npub struct Document {\n  pub id: String,\n  #[serde(rename = \"type\")]\n  pub doc_type: CollabType,\n  pub workspace_id: String,\n  pub content: String,\n}\n\n#[repr(u8)]\n#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize_repr, Deserialize_repr)]\npub enum CollabType {\n  Document = 0,\n  Database = 1,\n  WorkspaceDatabase = 2,\n  Folder = 3,\n  DatabaseRow = 4,\n  UserAwareness = 5,\n  Unknown = 6,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct TranslateRowParams {\n  pub workspace_id: String,\n  pub data: TranslateRowData,\n}\n\n/// Represents different types of content that can be used to summarize a database row.\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct TranslateRowData {\n  pub cells: Vec<TranslateItem>,\n  pub language: String,\n  pub include_header: bool,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct TranslateItem {\n  pub title: String,\n  pub content: String,\n}\n#[derive(Clone, Debug, Default, Serialize, Deserialize)]\npub struct TranslateRowResponse {\n  pub items: Vec<HashMap<String, String>>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)]\npub enum EmbeddingModel {\n  #[serde(rename = \"text-embedding-3-small\")]\n  TextEmbedding3Small,\n  #[serde(rename = \"text-embedding-3-large\")]\n  TextEmbedding3Large,\n  #[serde(rename = \"text-embedding-ada-002\")]\n  TextEmbeddingAda002,\n}\n\nimpl EmbeddingModel {\n  /// Returns the default embedding model used in this system.\n  ///\n  /// This model is hardcoded and used to generate embeddings whose dimensions are\n  /// reflected in the PostgreSQL database schema. Changing the default model may\n  /// require a migration to create a new table with the appropriate dimensions.\n  pub fn default_model() -> Self {\n    EmbeddingModel::TextEmbedding3Small\n  }\n\n  pub fn supported_models() -> &'static [&'static str] {\n    &[\n      \"text-embedding-ada-002\",\n      \"text-embedding-3-small\",\n      \"text-embedding-3-large\",\n    ]\n  }\n\n  pub fn max_token(&self) -> usize {\n    match self {\n      EmbeddingModel::TextEmbeddingAda002 => 8191,\n      EmbeddingModel::TextEmbedding3Large => 8191,\n      EmbeddingModel::TextEmbedding3Small => 8191,\n    }\n  }\n\n  pub fn default_dimensions(&self) -> u32 {\n    match self {\n      EmbeddingModel::TextEmbeddingAda002 => 1536,\n      EmbeddingModel::TextEmbedding3Large => 3072,\n      EmbeddingModel::TextEmbedding3Small => 1536,\n    }\n  }\n\n  pub fn name(&self) -> &'static str {\n    match self {\n      EmbeddingModel::TextEmbeddingAda002 => \"text-embedding-ada-002\",\n      EmbeddingModel::TextEmbedding3Large => \"text-embedding-3-large\",\n      EmbeddingModel::TextEmbedding3Small => \"text-embedding-3-small\",\n    }\n  }\n\n  pub fn from_name(name: &str) -> Option<Self> {\n    match name {\n      \"text-embedding-ada-002\" => Some(EmbeddingModel::TextEmbeddingAda002),\n      \"text-embedding-3-large\" => Some(EmbeddingModel::TextEmbedding3Large),\n      \"text-embedding-3-small\" => Some(EmbeddingModel::TextEmbedding3Small),\n      _ => None,\n    }\n  }\n}\n\nimpl Display for EmbeddingModel {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      EmbeddingModel::TextEmbedding3Small => write!(f, \"text-embedding-3-small\"),\n      EmbeddingModel::TextEmbedding3Large => write!(f, \"text-embedding-3-large\"),\n      EmbeddingModel::TextEmbeddingAda002 => write!(f, \"text-embedding-ada-002\"),\n    }\n  }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct RepeatedLocalAIPackage(pub Vec<AppFlowyOfflineAI>);\n\n#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]\npub struct AppFlowyOfflineAI {\n  pub app_name: String,\n  pub ai_plugin_name: String,\n  pub version: String,\n  pub url: String,\n  pub etag: String,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]\npub struct LLMModel {\n  pub llm_id: i64,\n  pub provider: String,\n  pub embedding_model: ModelInfo,\n  pub chat_model: ModelInfo,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]\npub struct ModelInfo {\n  pub name: String,\n  pub file_name: String,\n  pub file_size: i64,\n  pub requirements: String,\n  pub download_url: String,\n  pub desc: String,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct LocalAIConfig {\n  pub models: Vec<LLMModel>,\n  pub plugin: AppFlowyOfflineAI,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct AvailableModel {\n  pub name: String,\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<serde_json::Value>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ModelList {\n  pub models: Vec<AvailableModel>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CreateChatContext {\n  pub chat_id: String,\n  pub context_loader: String,\n  pub content: String,\n  pub chunk_size: i32,\n  pub chunk_overlap: i32,\n  pub metadata: serde_json::Value,\n}\n\nimpl CreateChatContext {\n  pub fn new(chat_id: String, context_loader: String, text: String) -> Self {\n    CreateChatContext {\n      chat_id,\n      context_loader,\n      content: text,\n      chunk_size: 2000,\n      chunk_overlap: 20,\n      metadata: json!({}),\n    }\n  }\n\n  pub fn with_metadata<T: Serialize>(mut self, metadata: T) -> Self {\n    self.metadata = json!(metadata);\n    self\n  }\n}\n\nimpl Display for CreateChatContext {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"Create Chat context: {{ chat_id: {}, content_type: {}, content size: {},  metadata: {:?} }}\",\n      self.chat_id,\n      self.context_loader,\n      self.content.len(),\n      self.metadata\n    ))\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct CustomPrompt {\n  pub system: String,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CalculateSimilarityParams {\n  pub workspace_id: Uuid,\n  pub input: String,\n  pub expected: String,\n  pub use_embedding: bool,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SimilarityResponse {\n  pub score: f64,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CompletionMessage {\n  pub role: String, // human, ai, or system\n  pub content: String,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CompletionMetadata {\n  /// A unique identifier for the object. Object could be a document id.\n  pub object_id: Uuid,\n  /// The workspace identifier.\n  ///\n  /// This field must be provided when generating images. We use workspace ID to track image usage.\n  pub workspace_id: Option<Uuid>,\n  /// A list of relevant document IDs.\n  ///\n  /// When using completions for document-related tasks, this should include the document ID.\n  /// In some cases, `object_id` may be the same as the document ID.\n  pub rag_ids: Option<Vec<String>>,\n  /// For the AI completion feature (the AI writer), pass the conversation history as input.\n  /// This history helps the AI understand the context of the conversation.\n  #[serde(default, skip_serializing_if = \"Option::is_none\")]\n  pub completion_history: Option<Vec<CompletionMessage>>,\n  /// When completion type is 'CustomPrompt', this field should be provided.\n  #[serde(default, skip_serializing_if = \"Option::is_none\")]\n  pub custom_prompt: Option<CustomPrompt>,\n  /// The id of the prompt used for the completion\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub prompt_id: Option<String>,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct CompleteTextParams {\n  pub text: String,\n  pub completion_type: Option<CompletionType>,\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<CompletionMetadata>,\n  #[serde(default)]\n  pub format: ResponseFormat,\n}\n\nimpl CompleteTextParams {\n  pub fn new_with_completion_type(\n    text: String,\n    completion_type: CompletionType,\n    metadata: Option<CompletionMetadata>,\n  ) -> Self {\n    Self {\n      text,\n      completion_type: Some(completion_type),\n      metadata,\n      format: Default::default(),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/src/error.rs",
    "content": "#[derive(Debug, thiserror::Error)]\npub enum AIError {\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n\n  #[error(\"Request timeout:{0}\")]\n  RequestTimeout(String),\n\n  #[error(\"Payload too large:{0}\")]\n  PayloadTooLarge(String),\n\n  #[error(\"Invalid request:{0}\")]\n  InvalidRequest(String),\n\n  #[error(transparent)]\n  SerdeError(#[from] serde_json::Error),\n\n  #[error(\"Service unavailable:{0}\")]\n  ServiceUnavailable(String),\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/src/lib.rs",
    "content": "#[cfg(feature = \"client-api\")]\npub mod client;\n\n#[cfg(feature = \"dto\")]\npub mod dto;\n\npub mod error;\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/chat_test/completion_test.rs",
    "content": "use crate::appflowy_ai_client;\nuse appflowy_ai_client::client::collect_stream_text;\nuse appflowy_ai_client::dto::{\n  CompleteTextParams, CompletionMetadata, CompletionType, CustomPrompt, OutputContent,\n  OutputLayout, ResponseFormat,\n};\n\n#[tokio::test]\nasync fn completion_explain_test() {\n  let client = appflowy_ai_client();\n  let params = CompleteTextParams {\n    text: \"Snowboarding\".to_string(),\n    completion_type: Some(CompletionType::Explain),\n    metadata: Some(CompletionMetadata {\n      object_id: uuid::Uuid::new_v4(),\n      workspace_id: Some(uuid::Uuid::new_v4()),\n      rag_ids: None,\n      completion_history: None,\n      custom_prompt: None,\n      prompt_id: None,\n    }),\n    format: ResponseFormat::default(),\n  };\n  let stream = client\n    .stream_completion_text(params, \"gpt-4o-mini\")\n    .await\n    .unwrap();\n  let text = collect_stream_text(stream).await;\n  assert!(!text.is_empty());\n}\n\n#[tokio::test]\nasync fn completion_image_test() {\n  let client = appflowy_ai_client();\n  let params = CompleteTextParams {\n    text: \"A yellow cat\".to_string(),\n    completion_type: Some(CompletionType::ImproveWriting),\n    metadata: Some(CompletionMetadata {\n      object_id: uuid::Uuid::new_v4(),\n      workspace_id: Some(uuid::Uuid::new_v4()),\n      rag_ids: None,\n      completion_history: None,\n      custom_prompt: None,\n      prompt_id: None,\n    }),\n    format: ResponseFormat {\n      output_content: OutputContent::IMAGE,\n      ..Default::default()\n    },\n  };\n  let stream = client\n    .stream_completion_text(params, \"gpt-4o-mini\")\n    .await\n    .unwrap();\n  let text = collect_stream_text(stream).await;\n  println!(\"{}\", text);\n  assert!(text.contains(\"http://localhost\"));\n}\n\n#[tokio::test]\nasync fn continue_writing_test() {\n  let client = appflowy_ai_client();\n  let params = CompleteTextParams {\n    text: \"I feel hungry\".to_string(),\n    completion_type: Some(CompletionType::ImproveWriting),\n    metadata: None,\n    format: ResponseFormat {\n      output_layout: OutputLayout::SimpleTable,\n      ..Default::default()\n    },\n  };\n  let stream = client\n    .stream_completion_text(params, \"gpt-4o-mini\")\n    .await\n    .unwrap();\n  let text = collect_stream_text(stream).await;\n  assert!(!text.is_empty());\n  println!(\"{}\", text);\n}\n\n#[tokio::test]\nasync fn make_text_shorter_text() {\n  let client = appflowy_ai_client();\n  let params = CompleteTextParams {\n        text: \"I have an immense passion and deep-seated affection for Rust, a modern, multi-paradigm, high-performance programming language that I find incredibly satisfying to use due to its focus on safety, speed, and concurrency\".to_string(),\n        completion_type: Some(CompletionType::MakeShorter),\n        metadata: None,\n        format: ResponseFormat::default(),\n    };\n  let stream = client\n    .stream_completion_text(params, \"gpt-4o-mini\")\n    .await\n    .unwrap();\n\n  let text = collect_stream_text(stream).await;\n\n  // the response would be something like:\n  // I'm deeply passionate about Rust, a modern, high-performance programming language, due to its emphasis on safety, speed, and concurrency\n  assert!(!text.is_empty());\n  println!(\"{}\", text);\n}\n\n#[tokio::test]\nasync fn custom_prompt_test() {\n  let client = appflowy_ai_client();\n  let params = CompleteTextParams {\n    text: \"A yellow cat\".to_string(),\n    completion_type: Some(CompletionType::CustomPrompt),\n    metadata: Some(CompletionMetadata {\n      object_id: uuid::Uuid::new_v4(),\n      workspace_id: Some(uuid::Uuid::new_v4()),\n      rag_ids: None,\n      completion_history: None,\n      custom_prompt: Some(CustomPrompt {\n        system: \"You are a talented artist who excels at providing detailed, creative instructions on how to draw a picture\".to_string(),\n      }),\n      prompt_id: None,\n    }),\n    format: Default::default(),\n  };\n  let stream = client\n    .stream_completion_text(params, \"gpt-4o-mini\")\n    .await\n    .unwrap();\n  let text = collect_stream_text(stream).await;\n  println!(\"{}\", text);\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/chat_test/context_test.rs",
    "content": "use crate::appflowy_ai_client;\nuse appflowy_ai_client::dto::CreateChatContext;\n#[tokio::test]\nasync fn create_chat_context_test() {\n  let client = appflowy_ai_client();\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let context = CreateChatContext {\n    chat_id: chat_id.clone(),\n    context_loader: \"text\".to_string(),\n    content: \"I have lived in the US for five years\".to_string(),\n    chunk_size: 1000,\n    chunk_overlap: 20,\n    metadata: Default::default(),\n  };\n  client.create_chat_text_context(context).await.unwrap();\n  let resp = client\n    .send_question(\n      &uuid::Uuid::new_v4().to_string(),\n      &chat_id,\n      1,\n      \"Where I live?\",\n      \"gpt-4o-mini\",\n      None,\n    )\n    .await\n    .unwrap();\n  // response will be something like:\n  // Based on the context you provided, you have lived in the US for five years. Therefore, it is likely that you currently live in the US\n  assert!(!resp.content.is_empty());\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/chat_test/mod.rs",
    "content": "mod completion_test;\nmod context_test;\nmod model_config_test;\nmod qa_test;\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/chat_test/model_config_test.rs",
    "content": "use crate::appflowy_ai_client;\n\n#[tokio::test]\nasync fn get_model_list_test() {\n  let client = appflowy_ai_client();\n  let models = client.get_model_list().await.unwrap().models;\n  assert!(models.len() >= 5, \"models.len() = {}\", models.len());\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/chat_test/qa_test.rs",
    "content": "use crate::appflowy_ai_client;\n\n#[tokio::test]\nasync fn qa_test() {\n  let client = appflowy_ai_client();\n  client.health_check().await.unwrap();\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let resp = client\n    .send_question(\n      &uuid::Uuid::new_v4().to_string(),\n      &chat_id,\n      1,\n      \"I feel hungry\",\n      \"gpt-4o\",\n      None,\n    )\n    .await\n    .unwrap();\n  assert!(!resp.content.is_empty());\n\n  let questions = client\n    .get_related_question(&chat_id, &1, \"gpt-4o-mini\")\n    .await\n    .unwrap()\n    .items;\n  println!(\"questions: {:?}\", questions);\n  assert_eq!(questions.len(), 3)\n}\n\n#[tokio::test]\nasync fn download_package_test() {\n  let client = appflowy_ai_client();\n  let packages = client.get_local_ai_package(\"macos\").await.unwrap();\n  assert!(!packages.0.is_empty());\n  println!(\"packages: {:?}\", packages);\n}\n\n#[tokio::test]\nasync fn get_local_ai_config_test() {\n  let client = appflowy_ai_client();\n  let config = client\n    .get_local_ai_config(\"macos\", Some(\"0.6.10\".to_string()))\n    .await\n    .unwrap();\n  assert!(!config.models.is_empty());\n\n  assert!(!config.models[0].embedding_model.download_url.is_empty());\n  assert!(!config.models[0].chat_model.download_url.is_empty());\n\n  assert!(!config.plugin.version.is_empty());\n  assert!(!config.plugin.url.is_empty());\n  println!(\"packages: {:?}\", config);\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/index_test/index_search_test.rs",
    "content": "use appflowy_ai_client::client::AppFlowyAIClient;\nuse appflowy_ai_client::dto::{CollabType, Document, SearchDocumentsRequest};\n\n#[tokio::test]\nasync fn index_search() {\n  let client = appflowy_ai_client();\n\n  client\n    .index_documents(&[\n      Document {\n        id: \"test-doc1\".to_string(),\n        doc_type: CollabType::Document,\n        workspace_id: \"test-workspace1\".to_string(),\n        content: \"Relevant. This is an important test document. It should appear in results.\"\n          .to_string(),\n      },\n      Document {\n        id: \"test-doc2\".to_string(),\n        doc_type: CollabType::Document,\n        workspace_id: \"test-workspace1\".to_string(),\n        content:\n          \"Irrelevant. This is an unimportant test document. It shouldn't appear in results.\"\n            .to_string(),\n      },\n      Document {\n        id: \"test-doc3\".to_string(),\n        doc_type: CollabType::Document,\n        workspace_id: \"test-workspace2\".to_string(),\n        content:\n          \"Irrelevant. This is an unimportant test document. It shouldn't appear in results.\"\n            .to_string(),\n      },\n    ])\n    .await\n    .unwrap();\n\n  let docs = client\n    .search_documents(&SearchDocumentsRequest {\n      workspaces: vec![\"test-workspace1\".to_string()],\n      query: \"relevant\".to_string(),\n      result_count: Some(1),\n    })\n    .await\n    .unwrap();\n\n  assert_eq!(docs.len(), 1);\n  assert_eq!(docs[0].id, \"test-doc1\".to_string());\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/index_test/mod.rs",
    "content": "mod index_search_test;\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/main.rs",
    "content": "use appflowy_ai_client::client::AppFlowyAIClient;\nuse std::sync::Once;\nuse tracing_subscriber::fmt::Subscriber;\nuse tracing_subscriber::util::SubscriberInitExt;\nuse tracing_subscriber::EnvFilter;\n\nmod chat_test;\nmod row_test;\n\n// mod index_test;\n\npub fn appflowy_ai_client() -> AppFlowyAIClient {\n  setup_log();\n  AppFlowyAIClient::new(\"http://localhost:5001\")\n}\n\npub fn setup_log() {\n  static START: Once = Once::new();\n  START.call_once(|| {\n    let level = std::env::var(\"RUST_LOG\").unwrap_or(\"trace\".to_string());\n    let mut filters = vec![];\n    filters.push(format!(\"appflowy_ai_client={}\", level));\n    std::env::set_var(\"RUST_LOG\", filters.join(\",\"));\n\n    let subscriber = Subscriber::builder()\n      .with_ansi(true)\n      .with_env_filter(EnvFilter::from_default_env())\n      .finish();\n    subscriber.try_init().unwrap();\n  });\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/row_test/mod.rs",
    "content": "mod summarize_test;\nmod translate_test;\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/row_test/summarize_test.rs",
    "content": "use crate::appflowy_ai_client;\nuse serde_json::json;\n\n#[tokio::test]\nasync fn summarize_row_test() {\n  let client = appflowy_ai_client();\n  let json = json!({\"name\": \"Jack\", \"age\": 25, \"city\": \"New York\"});\n\n  let result = client\n    .summarize_row(json.as_object().unwrap(), \"gpt-4o-mini\")\n    .await\n    .unwrap();\n  result.text.contains(\"Jack\");\n  result.text.contains(\"New York\");\n  println!(\"{:?}\", result);\n}\n"
  },
  {
    "path": "libs/appflowy-ai-client/tests/row_test/translate_test.rs",
    "content": "use crate::appflowy_ai_client;\n\nuse appflowy_ai_client::dto::{TranslateItem, TranslateRowData};\n\n#[tokio::test]\nasync fn translate_row_test() {\n  let client = appflowy_ai_client();\n\n  let mut cells = Vec::new();\n  for (key, value) in [(\"book name\", \"Atomic Habits\"), (\"author\", \"James Clear\")].iter() {\n    cells.push(TranslateItem {\n      title: key.to_string(),\n      content: value.to_string(),\n    });\n  }\n\n  let data = TranslateRowData {\n    cells,\n    language: \"Chinese\".to_string(),\n    include_header: false,\n  };\n\n  let result = client.translate_row(data, \"gpt-4o-mini\").await.unwrap();\n  assert_eq!(result.items.len(), 2);\n}\n"
  },
  {
    "path": "libs/appflowy-proto/Cargo.toml",
    "content": "[package]\nname = \"appflowy-proto\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nuuid = { workspace = true, features = [\"serde\"] }\nbytes.workspace = true\nthiserror.workspace = true\ncollab-entity.workspace = true\ncollab.workspace = true\nprost = { version = \"0.13.4\", features = [\"derive\"] }\nserde = { workspace = true, features = [\"derive\"] }\nserde_repr.workspace = true\n\n[build-dependencies]\nprost-build = \"0.13.4\"\nprotoc-bin-vendored = { version = \"3.0\" }\n"
  },
  {
    "path": "libs/appflowy-proto/build.rs",
    "content": "use std::process::Command;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n  // If the `PROTOC` environment variable is set, don't use vendored `protoc`\n  std::env::var(\"PROTOC\").map(|_| ()).unwrap_or_else(|_| {\n    let protoc_path = protoc_bin_vendored::protoc_bin_path().expect(\"protoc bin path\");\n    let protoc_path_str = protoc_path.to_str().expect(\"protoc path to str\");\n\n    // Set the `PROTOC` environment variable to the path of the `protoc` binary.\n    std::env::set_var(\"PROTOC\", protoc_path_str);\n  });\n\n  let proto_files = vec![\n    \"proto/messages.proto\",\n    \"proto/collab.proto\",\n    \"proto/notification.proto\",\n  ];\n  for proto_file in &proto_files {\n    println!(\"cargo:rerun-if-changed={}\", proto_file);\n  }\n\n  prost_build::Config::new()\n    .out_dir(\"src/pb/\")\n    .compile_protos(&proto_files, &[\"proto/\"])?;\n\n  // Run rustfmt on the generated files.\n  let files = std::fs::read_dir(\"src/\")?\n    .filter_map(Result::ok)\n    .filter(|entry| {\n      entry\n        .path()\n        .extension()\n        .map(|ext| ext == \"rs\")\n        .unwrap_or(false)\n    })\n    .map(|entry| entry.path().display().to_string());\n\n  for file in files {\n    Command::new(\"rustfmt\").arg(file).status()?;\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "libs/appflowy-proto/proto/collab.proto",
    "content": "syntax = \"proto3\";\n\npackage collab;\n\n/**\n * Rid represents Redis stream message Id, which is a unique identifier\n * in scope of individual Redis stream - here workspace scope - assigned\n * to each update stored in Redis.\n *\n * Default: \"0-0\"\n */\nmessage Rid {\n    // UNIX epoch timestamp in milliseconds.\n    fixed64 timestamp = 1;\n\n    // In case when timestamps duplicate, this monotonically increasing\n    // sequence number is incremented and assigned.\n    uint32 counter = 2;\n}\n\n/**\n * SyncRequest message is send by either a server or a client, which informs about the\n * last collab state known to either party.\n *\n * If other side has more recent data, it should send `Update` message in the response.\n * If other side has missing data, it should send its own `SyncRequest` in the response.\n */\nmessage SyncRequest {\n    // Last Redis stream ID of the update received from the server, concerning corresponding\n    // collab, this message refers to.It\n    //\n    // The `Rid.timestamp` field can be used to notify clients when was the last time,\n    // collab has been synchronised.\n    Rid last_message_id = 1;\n\n    // Yjs Doc state vector encoded using lib0 v1 encoding.\n    bytes state_vector = 2;\n}\n\n/**\n * Update message is send either in response to `SyncRequest` or independently by\n * the client/server. It contains the Yjs doc update that can represent incremental\n * changes made over corresponding collab, or full document state.\n */\nmessage Update {\n    // Redis stream message ID assigned to this update, after it has been stored by\n    // server in Redis.\n    //\n    // For updates send by client, this field is not set.\n    Rid message_id = 1;\n\n    // Flags used to inform about encoding details:\n    // - 0x00 - `payload` encoded using lib0 v1 encoding.\n    // - 0x01 - `payload` encoded using lib0 v2 encoding.\n    //\n    // NOTE: in the future we could also include `payload` compression.\n    uint32 flags = 2;\n\n    // Binary update representing incremental changes over the collab,\n    // or entire collab state delta.\n    bytes payload = 3;\n}\n\n/**\n * AwarenessUpdate message is send to inform about the latest changes in the\n * Yjs doc awareness state.\n */\nmessage AwarenessUpdate {\n    // Yjs awareness update encoded using lib0 v1 encoding.\n    bytes payload = 1;\n}\n\n/**\n * AccessChanged message is sent only by the server when we recognise, that\n * connected client has lost the access to a corresponding collab.\n */\nmessage AccessChanged {\n    // Flag indicating if user has read access to corresponding collab.\n    bool can_read = 1;\n    // Flag indicating if user has write access to corresponding collab.\n    bool can_write = 2;\n    // (Optional) human readable comment about the reason for access change.\n    int32 reason = 3;\n}\n\nmessage CollabMessage {\n    // Unique collab identifier (UUID), which this message is related to.\n    // We're using string here, since it's easier to represent in web browser client.\n    string object_id = 1;\n    // Collab type - required by some of the collab read operations on the server.\n    // NOTE: hopefully we'll be able to get rid of it in the future.\n    int32 collab_type = 2;\n    oneof data {\n        SyncRequest sync_request = 3;\n        Update update = 4;\n        AwarenessUpdate awareness_update = 5;\n        AccessChanged access_changed = 6;\n    }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/proto/messages.proto",
    "content": "syntax = \"proto3\";\n\nimport \"collab.proto\";\nimport \"notification.proto\";\n\npackage messages;\n\n/**\n * All messages send between client/server are wrapped into a `Message`.\n */\nmessage Message {\n    oneof payload {\n        collab.CollabMessage collab_message = 1;\n        notification.WorkspaceNotification notification = 2;\n    }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/proto/notification.proto",
    "content": "syntax = \"proto3\";\n\npackage notification;\n\nmessage WorkspaceNotification {\n    oneof payload {\n        UserProfileChange profile_change = 1;\n        PermissionChanged permission_changed = 2;\n    }\n}\n\nmessage UserProfileChange {\n    int64 uid = 1;\n    optional string name = 2;\n    optional string email = 3;\n}\n\nmessage PermissionChanged {\n    string object_id = 1;\n    uint32 reason = 2;\n}"
  },
  {
    "path": "libs/appflowy-proto/src/client_message.rs",
    "content": "use crate::pb;\nuse crate::pb::collab_message::Data;\nuse crate::pb::message::Payload;\n#[rustfmt::skip]\nuse crate::pb::{message, SyncRequest};\nuse crate::shared::{Error, ObjectId, Rid, UpdateFlags};\nuse collab::preclude::sync::AwarenessUpdate;\nuse collab::preclude::updates::decoder::Decode;\nuse collab::preclude::{StateVector, Update};\nuse collab_entity::CollabType;\nuse prost::Message;\nuse std::fmt::{Debug, Formatter};\nuse uuid::Uuid;\n\n/// Represents messages sent from the client to the server through the WebSocket connection.\n/// ClientMessage is used to synchronize collaborative data between clients.\n#[derive(Clone)]\npub enum ClientMessage {\n  /// Requests synchronization by providing the client's state vector.\n  /// The server responds with updates the client is missing.\n  ///\n  /// # Fields\n  /// * `object_id` - The unique identifier of the collaborative object\n  /// * `collab_type` - The type of collaborative object (document, folder, etc.)\n  /// * `last_message_id` - The ID of the last message received by this client\n  /// * `state_vector` - A compressed representation of the client's document state\n  Manifest {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    last_message_id: Rid,\n    state_vector: Vec<u8>,\n  },\n\n  /// Sends local changes to be synchronized with other clients.\n  ///\n  /// # Fields\n  /// * `object_id` - The unique identifier of the collaborative object\n  /// * `collab_type` - The type of collaborative object\n  /// * `flags` - Encoding version flag (Lib0v1 or Lib0v2)\n  /// * `update` - The encoded changes to be applied\n  Update {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    flags: UpdateFlags,\n    update: Vec<u8>,\n  },\n\n  /// Shares user presence and status information with other clients.\n  ///\n  /// # Fields\n  /// * `object_id` - The unique identifier of the collaborative object\n  /// * `collab_type` - The type of collaborative object\n  /// * `awareness` - Encoded user presence data\n  AwarenessUpdate {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    awareness: Vec<u8>,\n  },\n}\n\nimpl Debug for ClientMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector,\n      } => {\n        let state_vector = StateVector::decode_v1(state_vector).map_err(|_| std::fmt::Error)?;\n        f.debug_struct(\"Manifest\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"last_message_id\", &last_message_id)\n          .field(\"state_vector\", &state_vector)\n          .finish()\n      },\n      ClientMessage::Update {\n        object_id,\n        collab_type,\n        flags,\n        update,\n      } => {\n        let update = match flags {\n          UpdateFlags::Lib0v1 => Update::decode_v1(update),\n          UpdateFlags::Lib0v2 => Update::decode_v2(update),\n        }\n        .map_err(|_| std::fmt::Error)?;\n\n        f.debug_struct(\"Update\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"flags\", &flags)\n          .field(\"update\", &update)\n          .finish()\n      },\n      ClientMessage::AwarenessUpdate {\n        object_id,\n        collab_type,\n        awareness,\n      } => {\n        let awareness = AwarenessUpdate::decode_v1(awareness).map_err(|_| std::fmt::Error)?;\n        f.debug_struct(\"AwarenessUpdate\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"awareness\", &awareness)\n          .finish()\n      },\n    }\n  }\n}\n\nimpl ClientMessage {\n  /// Returns a reference to the object ID contained in this message.\n  pub fn object_id(&self) -> &ObjectId {\n    match self {\n      ClientMessage::Manifest { object_id, .. } => object_id,\n      ClientMessage::Update { object_id, .. } => object_id,\n      ClientMessage::AwarenessUpdate { object_id, .. } => object_id,\n    }\n  }\n\n  /// Converts this ClientMessage into a serialized byte array.\n  ///\n  /// This is typically used before sending the message over the network.\n  pub fn into_bytes(self) -> Result<Vec<u8>, Error> {\n    Ok(pb::Message::from(self).encode_to_vec())\n  }\n\n  /// Creates a ClientMessage from a serialized byte array.\n  ///\n  /// This is typically used after receiving a message from the network.\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {\n    let proto = pb::Message::decode(bytes)?;\n    Self::try_from(proto)\n  }\n}\n\n/// Converts a ClientMessage into the protocol buffer message format.\n/// This is used for serialization before network transmission.\nimpl From<ClientMessage> for pb::Message {\n  fn from(value: ClientMessage) -> Self {\n    match value {\n      ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::SyncRequest(SyncRequest {\n            last_message_id: Some(pb::Rid {\n              timestamp: last_message_id.timestamp,\n              counter: last_message_id.seq_no as u32,\n            }),\n            state_vector,\n          })),\n        })),\n      },\n      ClientMessage::Update {\n        object_id,\n        collab_type,\n        flags,\n        update,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::Update(pb::Update {\n            message_id: None,\n            flags: flags as u8 as u32,\n            payload: update,\n          })),\n        })),\n      },\n      ClientMessage::AwarenessUpdate {\n        object_id,\n        collab_type,\n        awareness,\n      } => {\n        //\n        pb::Message {\n          payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n            object_id: object_id.to_string(),\n            collab_type: collab_type as i32,\n            data: Some(Data::AwarenessUpdate(pb::AwarenessUpdate {\n              payload: awareness,\n            })),\n          })),\n        }\n      },\n    }\n  }\n}\n\n/// Attempts to convert a protocol buffer message into a ClientMessage.\n/// This is used for deserialization after receiving a message from the network.\nimpl TryFrom<pb::Message> for ClientMessage {\n  type Error = Error;\n\n  fn try_from(value: pb::Message) -> Result<Self, Self::Error> {\n    match value.payload {\n      None => Err(Error::MissingFields),\n      Some(payload) => match payload {\n        Payload::CollabMessage(value) => {\n          let object_id = Uuid::parse_str(&value.object_id)?;\n          let collab_type = CollabType::from(value.collab_type);\n\n          match value.data {\n            Some(Data::SyncRequest(proto)) => Ok(ClientMessage::Manifest {\n              object_id,\n              collab_type,\n              last_message_id: Rid {\n                timestamp: proto\n                  .last_message_id\n                  .as_ref()\n                  .ok_or(Error::MissingFields)?\n                  .timestamp,\n                seq_no: proto\n                  .last_message_id\n                  .as_ref()\n                  .ok_or(Error::MissingFields)?\n                  .counter as u16,\n              },\n              state_vector: proto.state_vector,\n            }),\n            Some(Data::Update(proto)) => Ok(ClientMessage::Update {\n              object_id,\n              collab_type,\n              flags: UpdateFlags::try_from(proto.flags as u8)?,\n              update: proto.payload,\n            }),\n            Some(Data::AwarenessUpdate(proto)) => Ok(ClientMessage::AwarenessUpdate {\n              object_id,\n              collab_type,\n              awareness: proto.payload,\n            }),\n            _ => Err(Error::MissingFields),\n          }\n        },\n        Payload::Notification(_) => Err(Error::UnsupportedClientMessage),\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/src/lib.rs",
    "content": "mod client_message;\nmod pb;\nmod server_message;\nmod shared;\n\npub use client_message::*;\npub use server_message::*;\npub use shared::*;\n"
  },
  {
    "path": "libs/appflowy-proto/src/pb/collab.rs",
    "content": "// This file is @generated by prost-build.\n/// *\n/// Rid represents Redis stream message Id, which is a unique identifier\n/// in scope of individual Redis stream - here workspace scope - assigned\n/// to each update stored in Redis.\n///\n/// Default: \"0-0\"\n#[derive(Clone, Copy, PartialEq, ::prost::Message)]\npub struct Rid {\n  /// UNIX epoch timestamp in milliseconds.\n  #[prost(fixed64, tag = \"1\")]\n  pub timestamp: u64,\n  /// In case when timestamps duplicate, this monotonically increasing\n  /// sequence number is incremented and assigned.\n  #[prost(uint32, tag = \"2\")]\n  pub counter: u32,\n}\n/// *\n/// SyncRequest message is send by either a server or a client, which informs about the\n/// last collab state known to either party.\n///\n/// If other side has more recent data, it should send `Update` message in the response.\n/// If other side has missing data, it should send its own `SyncRequest` in the response.\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct SyncRequest {\n  /// Last Redis stream ID of the update received from the server, concerning corresponding\n  /// collab, this message refers to.It\n  ///\n  /// The `Rid.timestamp` field can be used to notify clients when was the last time,\n  /// collab has been synchronised.\n  #[prost(message, optional, tag = \"1\")]\n  pub last_message_id: ::core::option::Option<Rid>,\n  /// Yjs Doc state vector encoded using lib0 v1 encoding.\n  #[prost(bytes = \"vec\", tag = \"2\")]\n  pub state_vector: ::prost::alloc::vec::Vec<u8>,\n}\n/// *\n/// Update message is send either in response to `SyncRequest` or independently by\n/// the client/server. It contains the Yjs doc update that can represent incremental\n/// changes made over corresponding collab, or full document state.\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct Update {\n  /// Redis stream message ID assigned to this update, after it has been stored by\n  /// server in Redis.\n  ///\n  /// For updates send by client, this field is not set.\n  #[prost(message, optional, tag = \"1\")]\n  pub message_id: ::core::option::Option<Rid>,\n  /// Flags used to inform about encoding details:\n  /// - 0x00 - `payload` encoded using lib0 v1 encoding.\n  /// - 0x01 - `payload` encoded using lib0 v2 encoding.\n  ///\n  /// NOTE: in the future we could also include `payload` compression.\n  #[prost(uint32, tag = \"2\")]\n  pub flags: u32,\n  /// Binary update representing incremental changes over the collab,\n  /// or entire collab state delta.\n  #[prost(bytes = \"vec\", tag = \"3\")]\n  pub payload: ::prost::alloc::vec::Vec<u8>,\n}\n/// *\n/// AwarenessUpdate message is send to inform about the latest changes in the\n/// Yjs doc awareness state.\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct AwarenessUpdate {\n  /// Yjs awareness update encoded using lib0 v1 encoding.\n  #[prost(bytes = \"vec\", tag = \"1\")]\n  pub payload: ::prost::alloc::vec::Vec<u8>,\n}\n/// *\n/// AccessChanged message is sent only by the server when we recognise, that\n/// connected client has lost the access to a corresponding collab.\n#[derive(Clone, Copy, PartialEq, ::prost::Message)]\npub struct AccessChanged {\n  /// Flag indicating if user has read access to corresponding collab.\n  #[prost(bool, tag = \"1\")]\n  pub can_read: bool,\n  /// Flag indicating if user has write access to corresponding collab.\n  #[prost(bool, tag = \"2\")]\n  pub can_write: bool,\n  /// (Optional) human readable comment about the reason for access change.\n  #[prost(int32, tag = \"3\")]\n  pub reason: i32,\n}\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct CollabMessage {\n  /// Unique collab identifier (UUID), which this message is related to.\n  /// We're using string here, since it's easier to represent in web browser client.\n  #[prost(string, tag = \"1\")]\n  pub object_id: ::prost::alloc::string::String,\n  /// Collab type - required by some of the collab read operations on the server.\n  /// NOTE: hopefully we'll be able to get rid of it in the future.\n  #[prost(int32, tag = \"2\")]\n  pub collab_type: i32,\n  #[prost(oneof = \"collab_message::Data\", tags = \"3, 4, 5, 6\")]\n  pub data: ::core::option::Option<collab_message::Data>,\n}\n/// Nested message and enum types in `CollabMessage`.\npub mod collab_message {\n  #[derive(Clone, PartialEq, ::prost::Oneof)]\n  pub enum Data {\n    #[prost(message, tag = \"3\")]\n    SyncRequest(super::SyncRequest),\n    #[prost(message, tag = \"4\")]\n    Update(super::Update),\n    #[prost(message, tag = \"5\")]\n    AwarenessUpdate(super::AwarenessUpdate),\n    #[prost(message, tag = \"6\")]\n    AccessChanged(super::AccessChanged),\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/src/pb/messages.rs",
    "content": "// This file is @generated by prost-build.\n/// *\n/// All messages send between client/server are wrapped into a `Message`.\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct Message {\n  #[prost(oneof = \"message::Payload\", tags = \"1, 2\")]\n  pub payload: ::core::option::Option<message::Payload>,\n}\n/// Nested message and enum types in `Message`.\npub mod message {\n  #[derive(Clone, PartialEq, ::prost::Oneof)]\n  pub enum Payload {\n    #[prost(message, tag = \"1\")]\n    CollabMessage(super::super::collab::CollabMessage),\n    #[prost(message, tag = \"2\")]\n    Notification(super::super::notification::WorkspaceNotification),\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/src/pb/mod.rs",
    "content": "mod collab;\nmod messages;\npub mod notification;\n\npub use collab::*;\npub use messages::*;\n"
  },
  {
    "path": "libs/appflowy-proto/src/pb/notification.rs",
    "content": "// This file is @generated by prost-build.\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct WorkspaceNotification {\n  #[prost(oneof = \"workspace_notification::Payload\", tags = \"1, 2\")]\n  pub payload: ::core::option::Option<workspace_notification::Payload>,\n}\n/// Nested message and enum types in `WorkspaceNotification`.\npub mod workspace_notification {\n  #[derive(Clone, PartialEq, ::prost::Oneof)]\n  pub enum Payload {\n    #[prost(message, tag = \"1\")]\n    ProfileChange(super::UserProfileChange),\n    #[prost(message, tag = \"2\")]\n    PermissionChanged(super::PermissionChanged),\n  }\n}\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct UserProfileChange {\n  #[prost(int64, tag = \"1\")]\n  pub uid: i64,\n  #[prost(string, optional, tag = \"2\")]\n  pub name: ::core::option::Option<::prost::alloc::string::String>,\n  #[prost(string, optional, tag = \"3\")]\n  pub email: ::core::option::Option<::prost::alloc::string::String>,\n}\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct PermissionChanged {\n  #[prost(string, tag = \"1\")]\n  pub object_id: ::prost::alloc::string::String,\n  #[prost(uint32, tag = \"2\")]\n  pub reason: u32,\n}\n"
  },
  {
    "path": "libs/appflowy-proto/src/server_message.rs",
    "content": "use crate::pb;\nuse crate::pb::collab_message::Data;\nuse crate::pb::message::Payload;\nuse crate::pb::notification::{PermissionChanged, UserProfileChange};\n#[rustfmt::skip]\nuse crate::pb::{SyncRequest, message};\nuse crate::shared::{Error, ObjectId, Rid, UpdateFlags};\nuse bytes::Bytes;\nuse collab::preclude::sync::AwarenessUpdate;\nuse collab::preclude::updates::decoder::Decode;\nuse collab::preclude::{StateVector, Update};\nuse collab_entity::CollabType;\nuse pb::notification::workspace_notification::Payload as NotificationPayload;\nuse prost::Message;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::{Debug, Display, Formatter};\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]\n#[repr(u8)]\npub enum AccessChangedReason {\n  PermissionDenied = 0,\n  ObjectDeleted = 1,\n}\n\nimpl Display for AccessChangedReason {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      AccessChangedReason::PermissionDenied => write!(f, \"PermissionDenied\"),\n      AccessChangedReason::ObjectDeleted => write!(f, \"ObjectDeleted\"),\n    }\n  }\n}\n\n#[derive(Clone)]\npub enum ServerMessage {\n  Manifest {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    last_message_id: Rid,\n    state_vector: Vec<u8>,\n  },\n  Update {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    flags: UpdateFlags,\n    last_message_id: Rid,\n    update: Bytes,\n  },\n  AwarenessUpdate {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    awareness: Bytes,\n  },\n  AccessChanges {\n    object_id: ObjectId,\n    collab_type: CollabType,\n    can_read: bool,\n    can_write: bool,\n    reason: AccessChangedReason,\n  },\n  Notification {\n    notification: WorkspaceNotification,\n  },\n}\n\nimpl ServerMessage {\n  pub fn into_bytes(self) -> Result<Vec<u8>, Error> {\n    Ok(pb::Message::from(self).encode_to_vec())\n  }\n\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {\n    let proto = pb::Message::decode(bytes)?;\n    Self::try_from(proto)\n  }\n}\n\nimpl Debug for ServerMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ServerMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector,\n      } => {\n        let state_vector = StateVector::decode_v1(state_vector).map_err(|_| std::fmt::Error)?;\n        f.debug_struct(\"Manifest\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"last_message_id\", &last_message_id)\n          .field(\"state_vector\", &state_vector)\n          .finish()\n      },\n      ServerMessage::Update {\n        object_id,\n        collab_type,\n        flags,\n        last_message_id,\n        update,\n      } => {\n        let update = match flags {\n          UpdateFlags::Lib0v1 => Update::decode_v1(update),\n          UpdateFlags::Lib0v2 => Update::decode_v2(update),\n        }\n        .map_err(|_| std::fmt::Error)?;\n\n        f.debug_struct(\"Update\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"flags\", &flags)\n          .field(\"last_message_id\", &last_message_id)\n          .field(\"update\", &update)\n          .finish()\n      },\n      ServerMessage::AwarenessUpdate {\n        object_id,\n        collab_type,\n        awareness,\n      } => {\n        let awareness = AwarenessUpdate::decode_v1(awareness).map_err(|_| std::fmt::Error)?;\n        f.debug_struct(\"AwarenessUpdate\")\n          .field(\"object_id\", &object_id)\n          .field(\"collab_type\", &collab_type)\n          .field(\"awareness\", &awareness)\n          .finish()\n      },\n      ServerMessage::AccessChanges {\n        object_id,\n        collab_type,\n        can_read,\n        can_write,\n        reason,\n      } => f\n        .debug_struct(\"PermissionDenied\")\n        .field(\"object_id\", &object_id)\n        .field(\"collab_type\", &collab_type)\n        .field(\"can_read\", &can_read)\n        .field(\"can_write\", &can_write)\n        .field(\"reason\", &reason)\n        .finish(),\n      ServerMessage::Notification { notification } => f\n        .debug_struct(\"WorkspaceNotification\")\n        .field(\"notification\", &notification)\n        .finish(),\n    }\n  }\n}\n\nimpl From<ServerMessage> for pb::Message {\n  fn from(value: ServerMessage) -> Self {\n    match value {\n      ServerMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::SyncRequest(SyncRequest {\n            last_message_id: Some(pb::Rid {\n              timestamp: last_message_id.timestamp,\n              counter: last_message_id.seq_no as u32,\n            }),\n            state_vector,\n          })),\n        })),\n      },\n      ServerMessage::Update {\n        object_id,\n        collab_type,\n        flags,\n        last_message_id,\n        update,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::Update(pb::Update {\n            flags: flags as u8 as u32,\n            message_id: Some(pb::Rid {\n              timestamp: last_message_id.timestamp,\n              counter: last_message_id.seq_no as u32,\n            }),\n            payload: update.into(),\n          })),\n        })),\n      },\n      ServerMessage::AwarenessUpdate {\n        object_id,\n        collab_type,\n        awareness,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::AwarenessUpdate(pb::AwarenessUpdate {\n            payload: awareness.into(),\n          })),\n        })),\n      },\n      ServerMessage::AccessChanges {\n        object_id,\n        collab_type,\n        can_read,\n        can_write,\n        reason,\n      } => pb::Message {\n        payload: Some(message::Payload::CollabMessage(pb::CollabMessage {\n          object_id: object_id.to_string(),\n          collab_type: collab_type as i32,\n          data: Some(Data::AccessChanged(pb::AccessChanged {\n            can_read,\n            can_write,\n            reason: reason as i32,\n          })),\n        })),\n      },\n      ServerMessage::Notification { notification } => match notification {\n        WorkspaceNotification::UserProfileChange { uid, email, name } => pb::Message {\n          payload: Some(message::Payload::Notification(\n            pb::notification::WorkspaceNotification {\n              payload: Some(NotificationPayload::ProfileChange(UserProfileChange {\n                uid,\n                name,\n                email,\n              })),\n            },\n          )),\n        },\n        WorkspaceNotification::ObjectAccessChanged { object_id, reason } => pb::Message {\n          payload: Some(message::Payload::Notification(\n            pb::notification::WorkspaceNotification {\n              payload: Some(NotificationPayload::PermissionChanged(PermissionChanged {\n                object_id: object_id.to_string(),\n                reason: reason as u32,\n              })),\n            },\n          )),\n        },\n      },\n    }\n  }\n}\n\nimpl TryFrom<pb::Message> for ServerMessage {\n  type Error = Error;\n\n  fn try_from(value: pb::Message) -> Result<Self, Self::Error> {\n    match value.payload {\n      None => Err(Error::MissingFields),\n      Some(payload) => match payload {\n        Payload::CollabMessage(value) => {\n          let object_id = Uuid::parse_str(&value.object_id)?;\n          let collab_type = CollabType::from(value.collab_type);\n          match value.data {\n            Some(Data::SyncRequest(proto)) => {\n              let rid = proto.last_message_id.ok_or(Error::MissingFields)?;\n              Ok(ServerMessage::Manifest {\n                object_id,\n                collab_type,\n                last_message_id: Rid {\n                  timestamp: rid.timestamp,\n                  seq_no: rid.counter as u16,\n                },\n                state_vector: proto.state_vector,\n              })\n            },\n            Some(Data::Update(proto)) => {\n              let rid = proto.message_id.ok_or(Error::MissingFields)?;\n              Ok(ServerMessage::Update {\n                object_id,\n                collab_type,\n                flags: UpdateFlags::try_from(proto.flags as u8)\n                  .map_err(|_| Error::MissingFields)?,\n                last_message_id: Rid {\n                  timestamp: rid.timestamp,\n                  seq_no: rid.counter as u16,\n                },\n                update: proto.payload.into(),\n              })\n            },\n            Some(Data::AwarenessUpdate(proto)) => Ok(ServerMessage::AwarenessUpdate {\n              object_id,\n              collab_type,\n              awareness: proto.payload.into(),\n            }),\n            Some(Data::AccessChanged(proto)) => Ok(ServerMessage::AccessChanges {\n              object_id,\n              collab_type,\n              can_read: proto.can_read,\n              can_write: proto.can_write,\n              reason: AccessChangedReason::from(proto.reason),\n            }),\n            _ => Err(Error::MissingFields),\n          }\n        },\n        Payload::Notification(notification) => match notification.payload {\n          None => Err(Error::MissingFields),\n          Some(payload) => match payload {\n            NotificationPayload::ProfileChange(value) => Ok(ServerMessage::Notification {\n              notification: WorkspaceNotification::UserProfileChange {\n                uid: value.uid,\n                email: value.email,\n                name: value.name,\n              },\n            }),\n            NotificationPayload::PermissionChanged(value) => {\n              let object_id = Uuid::parse_str(&value.object_id)?;\n              Ok(ServerMessage::Notification {\n                notification: WorkspaceNotification::ObjectAccessChanged {\n                  object_id,\n                  reason: value.reason.into(),\n                },\n              })\n            },\n          },\n        },\n      },\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum WorkspaceNotification {\n  UserProfileChange {\n    uid: i64,\n    email: Option<String>,\n    name: Option<String>,\n  },\n  ObjectAccessChanged {\n    object_id: Uuid,\n    reason: AccessChangedReason,\n  },\n}\n\nimpl From<AccessChangedReason> for i32 {\n  fn from(value: AccessChangedReason) -> Self {\n    value as i32\n  }\n}\n\nimpl From<i32> for AccessChangedReason {\n  fn from(value: i32) -> Self {\n    match value {\n      0 => AccessChangedReason::PermissionDenied,\n      1 => AccessChangedReason::ObjectDeleted,\n      _ => AccessChangedReason::PermissionDenied,\n    }\n  }\n}\n\nimpl From<u32> for AccessChangedReason {\n  fn from(value: u32) -> Self {\n    match value {\n      0 => AccessChangedReason::PermissionDenied,\n      1 => AccessChangedReason::ObjectDeleted,\n      _ => AccessChangedReason::PermissionDenied,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/appflowy-proto/src/shared.rs",
    "content": "use collab::entity::EncodedCollab;\nuse std::fmt::{Debug, Display, Formatter};\nuse std::str::FromStr;\nuse uuid::Uuid;\n\npub type WorkspaceId = Uuid;\npub type ObjectId = Uuid;\n\n/// Redis stream message ID, parsed.\n#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Default, Hash)]\npub struct Rid {\n  pub timestamp: u64,\n  pub seq_no: u16,\n}\n\nimpl Rid {\n  pub fn new(timestamp: u64, seq_no: u16) -> Self {\n    Rid { timestamp, seq_no }\n  }\n\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {\n    if bytes.len() != 10 {\n      return Err(Error::InvalidRid);\n    }\n    let timestamp = u64::from_be_bytes([\n      bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],\n    ]);\n    let seq_no = u16::from_be_bytes([bytes[8], bytes[9]]);\n    Ok(Rid { timestamp, seq_no })\n  }\n\n  pub fn into_bytes(&self) -> [u8; 10] {\n    let mut bytes = [0; 10];\n    bytes[0..8].copy_from_slice(&self.timestamp.to_be_bytes());\n    bytes[8..10].copy_from_slice(&self.seq_no.to_be_bytes());\n    bytes\n  }\n}\n\nimpl Display for Rid {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    write!(f, \"{}-{}\", self.timestamp, self.seq_no)\n  }\n}\n\nimpl FromStr for Rid {\n  type Err = String;\n\n  fn from_str(s: &str) -> Result<Self, Self::Err> {\n    let mut parts = s.split('-');\n    let timestamp = parts\n      .next()\n      .ok_or(\"missing timestamp\")?\n      .parse()\n      .map_err(|e| format!(\"{}\", e))?;\n    let seq_no = parts\n      .next()\n      .ok_or(\"missing sequence number\")?\n      .parse()\n      .map_err(|e| format!(\"{}\", e))?;\n    Ok(Rid { timestamp, seq_no })\n  }\n}\n\n#[repr(u8)]\n#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]\npub enum UpdateFlags {\n  #[default]\n  Lib0v1 = 0,\n  Lib0v2 = 1,\n}\n\nimpl TryFrom<u8> for UpdateFlags {\n  type Error = Error;\n\n  fn try_from(value: u8) -> Result<Self, Self::Error> {\n    match value {\n      0 => Ok(UpdateFlags::Lib0v1),\n      1 => Ok(UpdateFlags::Lib0v2),\n      tag => Err(Error::UnsupportedFlag(tag)),\n    }\n  }\n}\n\n#[derive(Debug, Clone, thiserror::Error)]\npub enum Error {\n  #[error(\"failed to decode message: {0}\")]\n  ProtobufDecode(#[from] prost::DecodeError),\n  #[error(\"failed to encode message: {0}\")]\n  ProtobufEncode(#[from] prost::EncodeError),\n  #[error(\"failed to decode object id: {0}\")]\n  InvalidObjectId(#[from] uuid::Error),\n  #[error(\"failed to decode Redis stream message ID\")]\n  InvalidRid,\n  #[error(\"failed to decode message: missing fields\")]\n  MissingFields,\n  #[error(\"failed to decode message: unsupported flag for update: {0}\")]\n  UnsupportedFlag(u8),\n  #[error(\"failed to decode message: unknown collab type: {0}\")]\n  UnknownCollabType(u8),\n  #[error(\"Message does not match expected client message\")]\n  UnsupportedClientMessage,\n}\n\npub struct TimestampedEncodedCollab {\n  pub encoded_collab: EncodedCollab,\n  pub rid: Rid,\n}\n"
  },
  {
    "path": "libs/client-api/Cargo.toml",
    "content": "[package]\nname = \"client-api\"\nversion = \"0.2.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nreqwest = { workspace = true, features = [\"multipart\"] }\nanyhow.workspace = true\ngotrue = { path = \"../gotrue\" }\ntracing = { version = \"0.1\" }\nthiserror = \"1.0.56\"\nbytes = \"1.9.0\"\nuuid.workspace = true\nfutures-util = \"0.3.30\"\nfutures-core = \"0.3.30\"\nparking_lot = \"0.12.1\"\nbrotli = { version = \"3.4.0\", optional = true }\nasync-trait.workspace = true\nprost = \"0.13.3\"\nurl = \"2.5.0\"\nmime = \"0.3.17\"\ntokio = { workspace = true, features = [\"sync\", \"macros\"] }\ntokio-stream = { version = \"0.1.14\" }\nchrono = \"0.4\"\nclient-websocket = { workspace = true, features = [\"native-tls\"] }\nsemver = \"1.0.22\"\nzstd = { version = \"0.13.2\" }\n\ncollab = { workspace = true }\nyrs = { workspace = true }\ncollab-rt-protocol = { workspace = true }\nworkspace-template = { workspace = true, optional = true }\nserde_json.workspace = true\nserde.workspace = true\napp-error = { workspace = true, features = [\"tokio_error\", \"bincode_error\"] }\nscraper = { version = \"0.17.1\", optional = true }\narc-swap.workspace = true\n\nshared-entity = { workspace = true }\ncollab-rt-entity = { workspace = true }\nclient-api-entity.workspace = true\nserde_urlencoded = \"0.7.1\"\nfutures.workspace = true\npin-project.workspace = true\npercent-encoding = \"2.3.1\"\nlazy_static = { workspace = true }\nmime_guess = \"2.0.5\"\nappflowy-proto = { path = \"../appflowy-proto\" }\ndashmap.workspace = true\ntokio-tungstenite = { workspace = true, features = [\"stream\"] }\nrand = \"0.8.5\"\nsmallvec = { workspace = true, features = [\n  \"serde\",\n  \"const_generics\",\n  \"const_new\",\n  \"write\",\n] }\ncollab-plugins = { workspace = true, features = [] }\n\n[dev-dependencies]\ntokio = { version = \"1\", features = [\"macros\", \"time\", \"rt\"] }\n\n[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies]\ntokio-retry = \"0.3\"\ntokio-util = \"0.7\"\nrayon = \"1.10.0\"\ninfra = { workspace = true, features = [\"file_util\"] }\nbase64 = \"0.22\"\nmd5 = \"0.7\"\n\n[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies.tokio]\nworkspace = true\nfeatures = [\"sync\", \"net\"]\n\n[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies.collab-rt-entity]\nworkspace = true\nfeatures = [\"tungstenite\"]\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nwasm-bindgen-futures = \"0.4.40\"\ngetrandom = { version = \"0.2\", features = [\"js\"] }\ntokio = { workspace = true, features = [\"sync\"] }\nagain = { version = \"0.1.2\" }\n\n[features]\ndefault = [\"verbose_log\"]\ntest_util = [\"scraper\"]\ntemplate = [\"workspace-template\"]\nsync_verbose_log = [\"collab-rt-protocol/verbose_log\"]\ntest_fast_sync = []\nenable_brotli = [\"brotli\"]\nverbose_log = []\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/collab_sink.rs",
    "content": "use std::collections::BinaryHeap;\nuse std::collections::{HashMap, HashSet};\nuse std::ops::{Deref, DerefMut};\nuse std::sync::atomic::{AtomicBool, AtomicU64, Ordering};\nuse std::sync::{Arc, Weak};\nuse std::time::{Duration, Instant};\n\nuse anyhow::Error;\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse collab::lock::Mutex;\nuse futures_util::SinkExt;\nuse tokio::sync::{broadcast, watch};\nuse tokio::time::{interval, sleep};\nuse tracing::{error, trace, warn};\n\nuse crate::collab_sync::collab_stream::SeqNumCounter;\nuse crate::collab_sync::{SinkConfig, SyncError, SyncObject};\nuse collab_rt_entity::{ClientCollabMessage, MsgId, ServerCollabMessage, SinkMessage};\n\npub(crate) const SEND_INTERVAL: Duration = Duration::from_secs(8);\npub const COLLAB_SINK_DELAY_MILLIS: u64 = 500;\n\npub struct CollabSink<Sink> {\n  uid: i64,\n  config: SinkConfig,\n  object: SyncObject,\n  /// The [Sink] is used to send the messages to the remote. It might be a websocket sink or\n  /// other sink that implements the [SinkExt] trait.\n  sender: Arc<Mutex<Sink>>,\n  /// The [SinkQueue] is used to queue the messages that are waiting to be sent to the\n  /// remote. It will merge the messages if possible.\n  message_queue: Arc<parking_lot::Mutex<SinkQueue<ClientCollabMessage>>>,\n  sending_messages: Arc<parking_lot::Mutex<HashSet<MsgId>>>,\n  /// The [watch::Sender] is used to notify the [CollabSinkRunner] to process the pending messages.\n  /// Sending `false` will stop the [CollabSinkRunner].\n  notifier: Arc<watch::Sender<SinkSignal>>,\n  sync_state_tx: broadcast::Sender<CollabSyncState>,\n  state: Arc<CollabSinkState>,\n}\n\nimpl<Sink> Drop for CollabSink<Sink> {\n  fn drop(&mut self) {\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\"Drop CollabSink {}\", self.object.object_id);\n    }\n\n    //\n    let _ = self.notifier.send(SinkSignal::Stop);\n  }\n}\n\nimpl<E, Sink> CollabSink<Sink>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n{\n  pub fn new(\n    uid: i64,\n    object: SyncObject,\n    sink: Sink,\n    notifier: watch::Sender<SinkSignal>,\n    sync_state_tx: broadcast::Sender<CollabSyncState>,\n    config: SinkConfig,\n  ) -> Self {\n    let notifier = Arc::new(notifier);\n    let sender = Arc::new(Mutex::from(sink));\n    let message_queue = Arc::new(parking_lot::Mutex::new(SinkQueue::new()));\n    let sending_messages = Arc::new(parking_lot::Mutex::new(HashSet::new()));\n    let state = Arc::new(CollabSinkState::new());\n    let mut interval = interval(SEND_INTERVAL);\n    let weak_sending_messages = Arc::downgrade(&sending_messages);\n\n    let _weak_notifier = Arc::downgrade(&notifier);\n    let _origin = CollabOrigin::Client(CollabClient {\n      uid,\n      device_id: object.device_id.clone(),\n    });\n\n    let cloned_state = state.clone();\n    let weak_notifier = Arc::downgrade(&notifier);\n    tokio::spawn(async move {\n      // Initial delay to make sure the first tick waits for SEND_INTERVAL\n      sleep(SEND_INTERVAL).await;\n      loop {\n        interval.tick().await;\n        match weak_notifier.upgrade() {\n          Some(notifier) => {\n            // Removing the flying messages allows for the re-sending of the top k messages in the message queue.\n            if let Some(sending_messages) = weak_sending_messages.upgrade() {\n              // remove all the flying messages if the last sync is expired within the SEND_INTERVAL.\n              if cloned_state\n                .latest_sync\n                .is_time_for_next_sync(SEND_INTERVAL)\n                .await\n              {\n                sending_messages.lock().clear();\n              }\n            }\n\n            if notifier.send(SinkSignal::Proceed).is_err() {\n              break;\n            }\n          },\n          None => break,\n        }\n      }\n    });\n\n    Self {\n      uid,\n      object,\n      sender,\n      message_queue,\n      notifier,\n      sync_state_tx,\n      config,\n      sending_messages,\n      state,\n    }\n  }\n\n  /// Put the message into the queue and notify the sink to process the next message.\n  /// After the [Msg] was pushed into the [SinkQueue]. The queue will pop the next msg base on\n  /// its priority. And the message priority is determined by the [Msg] that implement the [Ord] and\n  /// [PartialOrd] trait. Check out the [CollabMessage] for more details.\n  ///\n  pub fn queue_msg(&self, f: impl FnOnce(MsgId) -> ClientCollabMessage) {\n    let _ = self.sync_state_tx.send(CollabSyncState::Syncing);\n    let mut msg_queue = self.message_queue.lock();\n    let msg_id = self.state.id_counter.next();\n    let new_msg = f(msg_id);\n    msg_queue.push_msg(msg_id, new_msg);\n    drop(msg_queue);\n    self.merge();\n\n    // Notify the sink to process the next message after 500ms.\n    let _ = self\n      .notifier\n      .send(SinkSignal::ProcessAfterMillis(COLLAB_SINK_DELAY_MILLIS));\n  }\n\n  /// When queue the init message, the sink will clear all the pending messages and send the init\n  /// message immediately.\n  pub fn queue_init_sync(&self, f: impl FnOnce(MsgId) -> ClientCollabMessage) {\n    let _ = self.sync_state_tx.send(CollabSyncState::Syncing);\n    self.clear();\n\n    // When the client is connected, remove all pending messages and send the init message.\n    let mut msg_queue = self.message_queue.lock();\n    let msg_id = self.state.id_counter.next();\n    let init_sync = f(msg_id);\n    msg_queue.push_msg(msg_id, init_sync);\n    self.state.did_queue_int_sync.store(true, Ordering::SeqCst);\n    let _ = self.notifier.send(SinkSignal::Proceed);\n  }\n\n  pub fn did_queue_init_sync(&self) -> bool {\n    self.state.did_queue_int_sync.load(Ordering::SeqCst)\n  }\n\n  /// Returns bool value to indicate whether the init sync message should be queued.\n  /// The init sync message should be queued if the message queue is empty or the first message\n  /// is not the init sync message.\n  pub fn should_queue_init_sync(&self) -> bool {\n    let msg_queue = self.message_queue.lock();\n    if let Some(msg) = msg_queue.peek() {\n      if msg.message().is_client_init_sync() {\n        return false;\n      }\n    }\n    true\n  }\n\n  pub fn clear(&self) {\n    self.message_queue.lock().clear();\n    self.sending_messages.lock().clear();\n  }\n\n  pub fn pause(&self) {\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\"{}:{} pause\", self.uid, self.object.object_id);\n    }\n\n    self.state.pause_ping.store(true, Ordering::SeqCst);\n  }\n\n  pub fn resume(&self) {\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\"{}:{} resume\", self.uid, self.object.object_id);\n    }\n\n    self.state.pause_ping.store(false, Ordering::SeqCst);\n  }\n\n  /// Notify the sink to process the next message and mark the current message as done.\n  /// Returns bool value to indicate whether the message is valid.\n  pub async fn validate_response(\n    &self,\n    msg_id: MsgId,\n    server_message: &ServerCollabMessage,\n    seq_num_counter: &Arc<SeqNumCounter>,\n  ) -> Result<bool, SyncError> {\n    // safety: msg_id is not None\n    let income_message_id = msg_id;\n    let mut sending_messages = self.sending_messages.lock();\n\n    // if the message id is not in the sending messages, it means the message is invalid.\n    if !sending_messages.contains(&income_message_id) {\n      if cfg!(feature = \"sync_verbose_log\") {\n        trace!(\n          \"{}: sending messages:{:?} not contains {}\",\n          self.object.object_id,\n          sending_messages,\n          income_message_id\n        );\n      }\n      return Ok(false);\n    }\n\n    let mut message_queue = self.message_queue.lock();\n    let mut is_valid = false;\n    // if sending_messages.contains(&income_message_id) {\n    if let Some(current_item) = message_queue.pop() {\n      if current_item.msg_id() != income_message_id {\n        error!(\n          \"{} expect message id:{}, but receive:{}\",\n          self.object.object_id,\n          current_item.msg_id(),\n          income_message_id,\n        );\n        message_queue.push(current_item);\n      } else {\n        is_valid = true;\n        sending_messages.remove(&income_message_id);\n      }\n    }\n\n    if is_valid {\n      if let ServerCollabMessage::ClientAck(ack) = server_message {\n        if let Some(seq_num) = ack.get_seq_num() {\n          seq_num_counter.store_ack_seq_num(seq_num);\n          seq_num_counter.check_ack_broadcast_contiguous(&self.object.object_id)?;\n        }\n      }\n    }\n    // Check if all non-ping messages have been sent\n    let all_non_ping_messages_sent = !message_queue\n      .iter()\n      .any(|item| !item.message().is_ping_sync());\n\n    // If there are no non-ping messages left in the queue, it indicates all messages have been sent\n    if all_non_ping_messages_sent {\n      if let Err(err) = self.sync_state_tx.send(CollabSyncState::Finished) {\n        error!(\n          \"Failed to send SinkState::Finished for object_id '{}': {}\",\n          self.object.object_id, err\n        );\n      }\n    } else if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\n        \"{}: pending count:{} ids:{}\",\n        self.object.object_id,\n        message_queue.len(),\n        message_queue\n          .iter()\n          .map(|item| item.msg_id().to_string())\n          .collect::<Vec<_>>()\n          .join(\",\")\n      );\n    }\n\n    Ok(is_valid)\n  }\n\n  async fn process_next_msg(&self) {\n    let is_empty_queue = self\n      .message_queue\n      .try_lock()\n      .map(|q| q.is_empty())\n      .unwrap_or(true);\n    if is_empty_queue {\n      return;\n    }\n\n    let items = {\n      let (mut msg_queue, mut sending_messages) = match (\n        self.message_queue.try_lock(),\n        self.sending_messages.try_lock(),\n      ) {\n        (Some(msg_queue), Some(sending_messages)) => (msg_queue, sending_messages),\n        _ => {\n          warn!(\n            \"{}: failed to acquire the lock of the sink, retry later\",\n            self.object.object_id\n          );\n          retry_later(Arc::downgrade(&self.notifier));\n          return;\n        },\n      };\n      get_next_batch_item(&self.state, &mut sending_messages, &mut msg_queue)\n    };\n    self.send_immediately(items).await;\n  }\n\n  async fn send_immediately(&self, items: Vec<QueueItem<ClientCollabMessage>>) {\n    if items.is_empty() {\n      return;\n    }\n\n    let message_ids = items.iter().map(|item| item.msg_id()).collect::<Vec<_>>();\n    let messages = items\n      .into_iter()\n      .map(|item| item.into_message())\n      .collect::<Vec<_>>();\n    match self.sender.try_lock() {\n      Ok(mut sender) => {\n        self.state.latest_sync.update_timestamp().await;\n        match sender.send(messages).await {\n          Ok(_) => {\n            if cfg!(feature = \"sync_verbose_log\") {\n              trace!(\n                \"🔥client sending {} messages {:?}\",\n                self.object.object_id,\n                message_ids\n              );\n            }\n          },\n          Err(err) => {\n            error!(\"Failed to send error: {:?}\", err.into());\n            self\n              .sending_messages\n              .lock()\n              .retain(|id| !message_ids.contains(id));\n          },\n        }\n      },\n      Err(_) => {\n        warn!(\"failed to acquire the lock of the sink, retry later\");\n        self\n          .sending_messages\n          .lock()\n          .retain(|id| !message_ids.contains(id));\n        retry_later(Arc::downgrade(&self.notifier));\n      },\n    }\n  }\n\n  fn merge(&self) {\n    if let (Some(sending_messages), Some(mut msg_queue)) = (\n      self.sending_messages.try_lock(),\n      self.message_queue.try_lock(),\n    ) {\n      let mut items: Vec<QueueItem<ClientCollabMessage>> = Vec::with_capacity(msg_queue.len());\n      let mut merged_ids = HashMap::new();\n      while let Some(next) = msg_queue.pop() {\n        // If the message is in the flying messages, it means the message is sending to the remote.\n        // So don't merge the message.\n        if sending_messages.contains(&next.msg_id()) {\n          items.push(next);\n          continue;\n        }\n\n        // Try to merge the next message with the last message. Only merge when:\n        // 1. The last message is not in the flying messages.\n        // 2. The last message can be merged and the next message can be merged.\n        // 3. The last message's payload size is less than the maximum payload size.\n        if let Some(last) = items.last_mut() {\n          let can_merge = !sending_messages.contains(&last.msg_id())\n            && last.message().payload_size() < self.config.maximum_payload_size\n            && last.mergeable()\n            && next.mergeable()\n            && last.merge(&next, &self.config.maximum_payload_size).is_ok();\n          if can_merge {\n            merged_ids\n              .entry(last.msg_id())\n              .or_insert(vec![])\n              .push(next.msg_id());\n\n            // If the last message is merged with the next message, don't push the next message\n            continue;\n          }\n        }\n        items.push(next);\n      }\n\n      if cfg!(feature = \"sync_verbose_log\") {\n        for (msg_id, merged_ids) in merged_ids {\n          trace!(\n            \"{}: merged {:?} messages into: {:?}\",\n            self.object.object_id,\n            merged_ids,\n            msg_id\n          );\n        }\n      }\n      msg_queue.extend(items);\n    }\n  }\n\n  /// Notify the sink to process the next message.\n  pub(crate) fn notify_next(&self) {\n    let _ = self.notifier.send(SinkSignal::Proceed);\n  }\n}\n\nfn get_next_batch_item(\n  state: &Arc<CollabSinkState>,\n  sending_messages: &mut HashSet<MsgId>,\n  msg_queue: &mut SinkQueue<ClientCollabMessage>,\n) -> Vec<QueueItem<ClientCollabMessage>> {\n  let mut next_sending_items = vec![];\n  let mut requeue_items = vec![];\n\n  while let Some(item) = msg_queue.pop() {\n    // If we've already selected 20 items for sending, or if the current message\n    // is already being sent (exists in sending_messages), we requeue the current item\n    // and break out of the loop to prevent sending too many messages at once or\n    // sending the same message twice.\n    if next_sending_items.len() >= 20 || sending_messages.contains(&item.msg_id()) {\n      requeue_items.push(item);\n      break;\n    }\n\n    // Check if the current item is an initial synchronization message.\n    let is_init_sync = item.message().is_client_init_sync();\n\n    // Determine if the message should be sent. Messages are sent if the initial sync\n    // has already been queued (did_queue_int_sync is true) or if it's an initial sync message.\n    // This ensures that initial sync messages are prioritized and that other messages\n    // are only sent after the initial sync has been queued.\n    if state.did_queue_int_sync.load(Ordering::SeqCst) || is_init_sync {\n      next_sending_items.push(item.clone());\n      requeue_items.push(item);\n      // If the current item is an initial sync message, we've prioritized it for sending,\n      // so break the loop to handle its sending immediately.\n      if is_init_sync {\n        break;\n      }\n    } else {\n      // If the current message does not meet the conditions for immediate sending\n      // (e.g., initial sync hasn't been queued yet and this isn't an initial sync message),\n      // we requeue it to attempt sending later.\n      requeue_items.push(item);\n    }\n  }\n\n  msg_queue.extend(requeue_items);\n  let message_ids = next_sending_items\n    .iter()\n    .map(|item| item.msg_id())\n    .collect::<Vec<_>>();\n  sending_messages.extend(message_ids);\n  next_sending_items\n}\n\nfn retry_later(weak_notifier: Weak<watch::Sender<SinkSignal>>) {\n  if let Some(notifier) = weak_notifier.upgrade() {\n    let _ = notifier.send(SinkSignal::ProcessAfterMillis(200));\n  }\n}\n\npub struct CollabSinkRunner;\n\nimpl CollabSinkRunner {\n  /// The runner will stop if the [CollabSink] was dropped or the notifier was closed.\n  pub async fn run<E, Sink>(\n    weak_sink: Weak<CollabSink<Sink>>,\n    mut notifier: watch::Receiver<SinkSignal>,\n  ) where\n    E: Into<anyhow::Error> + Send + Sync + 'static,\n    Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  {\n    loop {\n      // stops the runner if the notifier was closed.\n      if notifier.changed().await.is_err() {\n        break;\n      }\n      if let Some(sync_sink) = weak_sink.upgrade() {\n        let value = notifier.borrow().clone();\n        match value {\n          SinkSignal::Stop => break,\n          SinkSignal::Proceed => {\n            sync_sink.process_next_msg().await;\n          },\n          SinkSignal::ProcessAfterMillis(millis) => {\n            sleep(Duration::from_millis(millis)).await;\n            sync_sink.process_next_msg().await;\n          },\n        }\n      } else {\n        break;\n      }\n    }\n  }\n}\n\npub trait MsgIdCounter: Send + Sync + 'static {\n  /// Get the next message id. The message id should be unique.\n  fn next(&self) -> MsgId;\n}\n\n#[derive(Debug, Default)]\npub struct DefaultMsgIdCounter(Arc<AtomicU64>);\n\nimpl DefaultMsgIdCounter {\n  pub fn new() -> Self {\n    Self::default()\n  }\n  pub(crate) fn next(&self) -> MsgId {\n    self.0.fetch_add(1, Ordering::SeqCst)\n  }\n}\n\npub(crate) struct SyncTimestamp {\n  last_sync: Mutex<Instant>,\n}\n\nimpl SyncTimestamp {\n  fn new() -> Self {\n    let now = Instant::now();\n    SyncTimestamp {\n      last_sync: Mutex::from(now.checked_sub(Duration::from_secs(60)).unwrap_or(now)),\n    }\n  }\n\n  /// Indicate the duration is passed since the last sync. The last sync timestamp will be updated\n  /// after sending a new message\n  pub async fn is_time_for_next_sync(&self, duration: Duration) -> bool {\n    Instant::now().duration_since(*self.last_sync.lock().await) > duration\n  }\n\n  async fn update_timestamp(&self) {\n    let mut last_sync_locked = self.last_sync.lock().await;\n    *last_sync_locked = Instant::now();\n  }\n}\n\npub(crate) struct CollabSinkState {\n  pub(crate) latest_sync: SyncTimestamp,\n  pub(crate) pause_ping: AtomicBool,\n  pub(crate) id_counter: DefaultMsgIdCounter,\n  pub(crate) did_queue_int_sync: AtomicBool,\n}\n\nimpl CollabSinkState {\n  fn new() -> Self {\n    let msg_id_counter = DefaultMsgIdCounter::new();\n    CollabSinkState {\n      latest_sync: SyncTimestamp::new(),\n      pause_ping: AtomicBool::new(false),\n      id_counter: msg_id_counter,\n      did_queue_int_sync: Default::default(),\n    }\n  }\n}\n\n#[derive(Clone, Debug)]\npub enum CollabSyncState {\n  /// The sink is syncing the messages to the remote.\n  Syncing,\n  /// All the messages are synced to the remote.\n  Finished,\n}\n\nimpl CollabSyncState {\n  pub fn is_syncing(&self) -> bool {\n    matches!(self, CollabSyncState::Syncing)\n  }\n}\n\n#[derive(Clone)]\npub enum SinkSignal {\n  Stop,\n  Proceed,\n  ProcessAfterMillis(u64),\n}\n\npub(crate) struct SinkQueue<Msg> {\n  queue: BinaryHeap<QueueItem<Msg>>,\n}\n\nimpl<Msg> SinkQueue<Msg>\nwhere\n  Msg: SinkMessage,\n{\n  pub(crate) fn new() -> Self {\n    Self {\n      queue: Default::default(),\n    }\n  }\n\n  pub(crate) fn push_msg(&mut self, msg_id: MsgId, msg: Msg) {\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\"📩 queue: {}\", msg);\n    }\n\n    self.queue.push(QueueItem::new(msg, msg_id));\n  }\n}\n\nimpl<Msg> Deref for SinkQueue<Msg>\nwhere\n  Msg: SinkMessage,\n{\n  type Target = BinaryHeap<QueueItem<Msg>>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.queue\n  }\n}\n\nimpl<Msg> DerefMut for SinkQueue<Msg>\nwhere\n  Msg: SinkMessage,\n{\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    &mut self.queue\n  }\n}\n\n#[derive(Debug, Clone)]\npub(crate) struct QueueItem<Msg> {\n  inner: Msg,\n  msg_id: MsgId,\n}\n\nimpl<Msg> QueueItem<Msg>\nwhere\n  Msg: SinkMessage,\n{\n  pub fn new(msg: Msg, msg_id: MsgId) -> Self {\n    Self { inner: msg, msg_id }\n  }\n\n  pub fn message(&self) -> &Msg {\n    &self.inner\n  }\n\n  pub fn into_message(self) -> Msg {\n    self.inner\n  }\n\n  pub fn msg_id(&self) -> MsgId {\n    self.msg_id\n  }\n}\n\nimpl<Msg> QueueItem<Msg>\nwhere\n  Msg: SinkMessage,\n{\n  pub fn mergeable(&self) -> bool {\n    self.inner.mergeable()\n  }\n\n  pub fn merge(&mut self, other: &Self, max_size: &usize) -> Result<bool, Error> {\n    self.inner.merge(other.message(), max_size)\n  }\n}\n\nimpl<Msg> Eq for QueueItem<Msg> where Msg: Eq {}\n\nimpl<Msg> PartialEq for QueueItem<Msg>\nwhere\n  Msg: PartialEq,\n{\n  fn eq(&self, other: &Self) -> bool {\n    self.inner == other.inner\n  }\n}\n\nimpl<Msg> PartialOrd for QueueItem<Msg>\nwhere\n  Msg: PartialOrd + Ord,\n{\n  fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {\n    Some(self.cmp(other))\n  }\n}\n\nimpl<Msg> Ord for QueueItem<Msg>\nwhere\n  Msg: Ord,\n{\n  fn cmp(&self, other: &Self) -> std::cmp::Ordering {\n    self.inner.cmp(&other.inner)\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/collab_stream.rs",
    "content": "use std::borrow::BorrowMut;\nuse std::marker::PhantomData;\nuse std::sync::atomic::{AtomicU32, Ordering};\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\n\nuse arc_swap::ArcSwap;\nuse collab::core::origin::CollabOrigin;\nuse collab::lock::RwLock;\nuse collab::preclude::Collab;\nuse futures_util::{SinkExt, StreamExt};\nuse tokio::select;\nuse tokio_util::sync::CancellationToken;\nuse tracing::{error, instrument, trace, warn};\nuse uuid::Uuid;\nuse yrs::encoding::read::Cursor;\nuse yrs::updates::decoder::DecoderV1;\nuse yrs::updates::encoder::Encode;\nuse yrs::ReadTxn;\n\nuse client_api_entity::{validate_data_for_folder, CollabType};\nuse collab_rt_entity::{AckCode, ClientCollabMessage, ServerCollabMessage, ServerInit, UpdateSync};\nuse collab_rt_protocol::{\n  ClientSyncProtocol, CollabSyncProtocol, Message, MessageReader, SyncMessage,\n};\n\nuse crate::collab_sync::{\n  start_sync, CollabSink, MissUpdateReason, SyncError, SyncObject, SyncReason,\n};\n\npub type CollabRef = Weak<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>;\n\n/// Use to continuously receive updates from remote.\npub struct ObserveCollab<Sink, Stream> {\n  #[allow(dead_code)]\n  object_id: Uuid,\n  #[allow(dead_code)]\n  weak_collab: CollabRef,\n  phantom_sink: PhantomData<Sink>,\n  phantom_stream: PhantomData<Stream>,\n  // Use sequence number to check if the received updates/broadcasts are continuous.\n  #[allow(dead_code)]\n  seq_num_counter: Arc<SeqNumCounter>,\n}\n\nimpl<Sink, Stream> Drop for ObserveCollab<Sink, Stream> {\n  fn drop(&mut self) {\n    #[cfg(feature = \"sync_verbose_log\")]\n    trace!(\"Drop SyncStream {}\", self.object_id);\n  }\n}\n\nimpl<E, Sink, Stream> ObserveCollab<Sink, Stream>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  Stream: StreamExt<Item = Result<ServerCollabMessage, E>> + Send + Sync + Unpin + 'static,\n{\n  pub fn new(\n    origin: CollabOrigin,\n    object: SyncObject,\n    stream: Stream,\n    weak_collab: CollabRef,\n    sink: Weak<CollabSink<Sink>>,\n    periodic_sync_interval: Option<Duration>,\n  ) -> Self {\n    let object_id = object.object_id;\n    let cloned_weak_collab = weak_collab.clone() as CollabRef;\n    let seq_num_counter = Arc::new(SeqNumCounter::default());\n    let cloned_seq_num_counter = seq_num_counter.clone();\n    let init_sync_cancel_token = ArcSwap::new(Arc::new(CancellationToken::new()));\n    let arc_object = Arc::new(object);\n\n    if let Some(interval) = periodic_sync_interval {\n      tracing::trace!(\"setting periodic sync step 1 for {}\", object_id);\n      tokio::spawn(ObserveCollab::<Sink, Stream>::periodic_sync_step_1(\n        origin.clone(),\n        sink.clone(),\n        cloned_weak_collab.clone(),\n        interval,\n        object_id.to_string(),\n      ));\n    }\n    tokio::spawn(ObserveCollab::<Sink, Stream>::observer_collab_message(\n      origin,\n      arc_object,\n      stream,\n      cloned_weak_collab,\n      sink,\n      cloned_seq_num_counter,\n      init_sync_cancel_token,\n    ));\n    Self {\n      object_id,\n      weak_collab,\n      phantom_sink: Default::default(),\n      phantom_stream: Default::default(),\n      seq_num_counter,\n    }\n  }\n\n  /// Periodically run sync step 1 to make sure that there are no missing updates from other clients.\n  async fn periodic_sync_step_1(\n    origin: CollabOrigin,\n    weak_sink: Weak<CollabSink<Sink>>,\n    weak_collab: CollabRef,\n    interval: Duration,\n    object_id: String,\n  ) {\n    loop {\n      tokio::time::sleep(interval).await;\n      let sink = match weak_sink.upgrade() {\n        Some(sink) => sink,\n        None => break,\n      };\n\n      let collab = match weak_collab.upgrade() {\n        Some(collab) => collab,\n        None => break,\n      };\n\n      let sv = {\n        let lock = collab.read().await;\n        let sv = (*lock).borrow().transact().state_vector();\n        sv\n      };\n      let msg = Message::Sync(SyncMessage::SyncStep1(sv)).encode_v1();\n      trace!(\"Periodic sync step 1 for {}\", object_id);\n      sink.queue_msg(|msg_id| {\n        ClientCollabMessage::new_update_sync(UpdateSync::new(\n          origin.clone(),\n          object_id.clone(),\n          msg,\n          msg_id,\n        ))\n      });\n    }\n  }\n\n  // Spawn the stream that continuously reads the doc's updates from remote.\n  async fn observer_collab_message(\n    origin: CollabOrigin,\n    object: Arc<SyncObject>,\n    mut stream: Stream,\n    weak_collab: CollabRef,\n    weak_sink: Weak<CollabSink<Sink>>,\n    seq_num_counter: Arc<SeqNumCounter>,\n    cancel_token: ArcSwap<CancellationToken>,\n  ) {\n    while let Some(collab_message_result) = stream.next().await {\n      let collab = match weak_collab.upgrade() {\n        Some(collab) => collab,\n        None => break, // Collab dropped, stop the stream.\n      };\n\n      let sink = match weak_sink.upgrade() {\n        Some(sink) => sink,\n        None => break, // Sink dropped, stop the stream.\n      };\n\n      let msg = match collab_message_result {\n        Ok(msg) => msg,\n        Err(err) => {\n          warn!(\n            \"{} stream error:{}, stop receive incoming changes\",\n            object.object_id,\n            err.into()\n          );\n          break;\n        },\n      };\n\n      if let Err(error) = ObserveCollab::<Sink, Stream>::process_remote_message(\n        &object,\n        &collab,\n        &sink,\n        msg,\n        &seq_num_counter,\n      )\n      .await\n      {\n        match error {\n          SyncError::MissUpdates {\n            state_vector_v1,\n            reason,\n          } => {\n            let new_cancel_token = Arc::new(CancellationToken::new());\n            let old_cancel_token = cancel_token.swap(new_cancel_token.clone());\n            old_cancel_token.cancel();\n\n            let cloned_origin = origin.clone();\n            let cloned_object = object.clone();\n            let collab = collab.clone();\n            let sink = sink.clone();\n            let sync_reason = match state_vector_v1 {\n              None => SyncReason::ClientMissUpdates { reason },\n              Some(sv) => SyncReason::ServerMissUpdates {\n                state_vector_v1: sv,\n                reason,\n              },\n            };\n            tokio::spawn(async move {\n              select! {\n                _ = new_cancel_token.cancelled() => {\n                    trace!(\"{} cancel pull missing updates\", cloned_object.object_id);\n                },\n                _ = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {\n                   Self::pull_missing_updates(&cloned_origin, &cloned_object, &collab, &sink, sync_reason)\n                   .await;\n                }\n              }\n            });\n          },\n          SyncError::CannotApplyUpdate => {\n            let lock = collab.read().await;\n            if let Err(err) = start_sync(\n              origin.clone(),\n              &object,\n              (*lock).borrow(),\n              &sink,\n              SyncReason::ServerCannotApplyUpdate,\n            ) {\n              error!(\"Error while start sync: {}\", err);\n            }\n          },\n          SyncError::OverrideWithIncorrectData(_) => {\n            error!(\"Error while processing message: {}\", error);\n            break;\n          },\n          _ => {\n            error!(\"Error while processing message: {}\", error);\n          },\n        }\n      }\n    }\n  }\n\n  /// Continuously handle messages from the remote doc\n  async fn process_remote_message(\n    object: &SyncObject,\n    collab: &Arc<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>,\n    sink: &Arc<CollabSink<Sink>>,\n    msg: ServerCollabMessage,\n    seq_num_counter: &Arc<SeqNumCounter>,\n  ) -> Result<(), SyncError> {\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\"handle server: {:?}\", msg);\n    }\n\n    if let ServerCollabMessage::ClientAck(ack) = &msg {\n      let ack_code = ack.get_code();\n      // if the server can not apply the update, we start the init sync.\n      if ack_code == AckCode::CannotApplyUpdate {\n        return Err(SyncError::CannotApplyUpdate);\n      }\n\n      if ack_code == AckCode::MissUpdate {\n        // if the ack code is MissUpdate, it means the server has missed some updates. Client need to\n        // use the payload of the current message to calculate missing update. So any existing pending\n        // updates are no long needed.\n        sink.clear();\n\n        return Err(SyncError::MissUpdates {\n          state_vector_v1: Some(ack.payload.to_vec()),\n          reason: MissUpdateReason::ServerMissUpdates,\n        });\n      }\n    }\n\n    // msg_id will be None for [ServerBroadcast] or [ServerAwareness].\n    match msg.msg_id() {\n      None => {\n        // apply the broadcast data and then check the continuity of the broadcast sequence number.\n        Self::process_message_follow_protocol(object, &msg, collab, sink).await?;\n        sink.notify_next();\n\n        if let ServerCollabMessage::ServerBroadcast(ref data) = msg {\n          seq_num_counter.check_broadcast_contiguous(&object.object_id, data.seq_num)?;\n          seq_num_counter.store_broadcast_seq_num(data.seq_num);\n        }\n        Ok(())\n      },\n      Some(msg_id) => {\n        let is_valid = sink\n          .validate_response(msg_id, &msg, seq_num_counter)\n          .await?;\n\n        if is_valid {\n          Self::process_message_follow_protocol(object, &msg, collab, sink).await?;\n        }\n        sink.notify_next();\n        Ok(())\n      },\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn pull_missing_updates(\n    origin: &CollabOrigin,\n    object: &SyncObject,\n    collab: &Arc<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>,\n    sink: &Arc<CollabSink<Sink>>,\n    reason: SyncReason,\n  ) {\n    let lock = collab.read().await;\n    if let Err(err) = start_sync(origin.clone(), object, (*lock).borrow(), sink, reason) {\n      error!(\"Error while start sync: {}\", err);\n    }\n  }\n\n  async fn process_message_follow_protocol(\n    sync_object: &SyncObject,\n    msg: &ServerCollabMessage,\n    collab: &Arc<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>,\n    sink: &Arc<CollabSink<Sink>>,\n  ) -> Result<(), SyncError> {\n    if msg.payload().is_empty() {\n      return Ok(());\n    }\n\n    let payload = msg.payload().clone();\n    let message_origin = msg.origin().clone();\n    let sink = sink.clone();\n    let sync_object = sync_object.clone();\n    let collab = collab.clone();\n\n    // workaround for panic when applying updates. It can be removed in the future\n    let result = tokio::spawn(async move {\n      let mut decoder = DecoderV1::new(Cursor::new(&payload));\n      let reader = MessageReader::new(&mut decoder);\n      for yrs_message in reader {\n        let msg = yrs_message?;\n\n        // When the client receives a SyncStep1 message, it indicates that the server is requesting\n        // the client to send updates that the server is missing. This typically occurs when the client\n        // has been editing offline, resulting in the client's version of the collaboration object\n        // being ahead of the server's version. In response, the client prepares to send the missing updates.\n        let is_server_sync_step_1 = matches!(msg, Message::Sync(SyncMessage::SyncStep1(_)));\n\n        // If the collaboration object is of type [CollabType::Folder], data validation is required\n        // before sending the SyncStep1 to the server.\n        if is_server_sync_step_1 && sync_object.collab_type == CollabType::Folder {\n          let lock = collab.read().await;\n          validate_data_for_folder((*lock).borrow(), &sync_object.workspace_id.to_string())\n            .map_err(|err| SyncError::OverrideWithIncorrectData(err.to_string()))?;\n        }\n\n        if let Some(return_payload) = ClientSyncProtocol\n          .handle_message(&message_origin, &collab, msg)\n          .await?\n        {\n          let object_id = sync_object.object_id;\n          sink.queue_msg(|msg_id| {\n            if is_server_sync_step_1 {\n              ClientCollabMessage::new_server_init_sync(ServerInit::new(\n                message_origin.clone(),\n                object_id.to_string(),\n                return_payload,\n                msg_id,\n              ))\n            } else {\n              ClientCollabMessage::new_update_sync(UpdateSync::new(\n                message_origin.clone(),\n                object_id.to_string(),\n                return_payload,\n                msg_id,\n              ))\n            }\n          });\n        }\n      }\n      Ok::<_, SyncError>(())\n    })\n    .await;\n\n    result.unwrap_or_else(|err| {\n      error!(\"Panic while processing message: {:?}\", err);\n      Err(SyncError::Internal(anyhow::anyhow!(\n        \"Panic while processing message\"\n      )))\n    })\n  }\n}\n\n#[derive(Default)]\npub struct SeqNumCounter {\n  /// The sequence number of the last update broadcast by the server.\n  /// This counter is incremented by 1 each time the server applies an update.\n  pub broadcast_seq_counter: AtomicU32,\n  /// The sequence number of the last update acknowledged by a client.\n  /// This is set to the sequence number contained in the `CollabMessage::ClientAck` received from a client.\n  /// If this number is greater than `broadcast_seq_counter`, it indicates that some updates are missing on the client side,\n  /// prompting an initialization sync to rectify missing updates.\n  pub ack_seq_counter: AtomicU32,\n  pub miss_update_counter: AtomicU32,\n}\n\nimpl SeqNumCounter {\n  pub fn store_ack_seq_num(&self, seq_num: u32) -> u32 {\n    // If the broadcast sequence counter is 0, set it to the current sequence number.\n    if self.broadcast_seq_counter.load(Ordering::SeqCst) == 0 {\n      self.broadcast_seq_counter.store(seq_num, Ordering::SeqCst);\n    }\n\n    match self\n      .ack_seq_counter\n      .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {\n        // Check if the sequence number is less than the current one. A lower sequence number can indicate\n        // that the server has been restarted, or the collaboration group has been reinitialized.\n        if seq_num >= current {\n          Some(seq_num)\n        } else {\n          None\n        }\n      }) {\n      Ok(prev) => prev,\n      Err(prev) => {\n        self.ack_seq_counter.store(seq_num, Ordering::SeqCst);\n        prev\n      },\n    }\n  }\n\n  pub fn store_broadcast_seq_num(&self, broadcast_seq_num: u32) -> u32 {\n    match self\n      .broadcast_seq_counter\n      .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {\n        // Check if the sequence number is less than the current one. A lower sequence number can indicate\n        // that the server has been restarted, or the collaboration group has been reinitialized.\n        if broadcast_seq_num >= current {\n          Some(broadcast_seq_num)\n        } else {\n          None\n        }\n      }) {\n      Ok(prev) => prev,\n      Err(prev) => {\n        self\n          .broadcast_seq_counter\n          .store(broadcast_seq_num, Ordering::SeqCst);\n        prev\n      },\n    }\n  }\n\n  /// Checks if the given broadcast sequence number is contiguous with the current sequence.\n  ///\n  /// Verifies that the broadcast sequence number provided (`broadcast_seq_num`) follows directly after\n  /// the last known sequence number stored in the system (`current`).\n  ///\n  /// If there is a gap between the `broadcast_seq_num` and `current`, it indicates that some\n  /// messages may have been missed, and an error is returned.\n  pub fn check_broadcast_contiguous(\n    &self,\n    _object_id: &Uuid,\n    broadcast_seq_num: u32,\n  ) -> Result<(), SyncError> {\n    let current = self.broadcast_seq_counter.load(Ordering::SeqCst);\n    if current > 0 && broadcast_seq_num > current + 1 {\n      return Err(SyncError::MissUpdates {\n        state_vector_v1: None,\n        reason: MissUpdateReason::BroadcastSeqNotContinuous {\n          current,\n          expected: broadcast_seq_num,\n        },\n      });\n    }\n\n    Ok(())\n  }\n\n  pub fn check_ack_broadcast_contiguous(&self, object_id: &Uuid) -> Result<(), SyncError> {\n    let ack_seq_num = self.ack_seq_counter.load(Ordering::SeqCst);\n    let broadcast_seq_num = self.broadcast_seq_counter.load(Ordering::SeqCst);\n    if cfg!(feature = \"sync_verbose_log\") {\n      trace!(\n        \"receive {} seq_num, ack:{}, broadcast:{}\",\n        object_id,\n        ack_seq_num,\n        broadcast_seq_num,\n      );\n    }\n\n    if ack_seq_num > broadcast_seq_num {\n      // calculate the number of times the ack is greater than the broadcast. We don't do return MissingUpdates\n      // immediately, because the ack may be greater than the broadcast for a short time.\n      let old = self.miss_update_counter.fetch_add(1, Ordering::SeqCst);\n\n      if old + 1 >= 2 {\n        self.miss_update_counter.store(0, Ordering::SeqCst);\n        // Mark the broadcast sequence number as ack seq_num because a MissUpdates error triggers\n        // an initialization synchronization. After this initial sync, the ack and broadcast sequence\n        // numbers are expected to align, ensuring that all updates are synchronized.\n        self\n          .broadcast_seq_counter\n          .store(ack_seq_num, Ordering::SeqCst);\n\n        return Err(SyncError::MissUpdates {\n          state_vector_v1: None,\n          reason: MissUpdateReason::AckSeqAdvanceBroadcastSeq {\n            ack_seq: ack_seq_num,\n            broadcast_seq: broadcast_seq_num,\n          },\n        });\n      }\n    }\n\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/error.rs",
    "content": "use collab_rt_protocol::RTProtocolError;\nuse std::fmt::Display;\n\n#[derive(Debug, thiserror::Error)]\npub enum SyncError {\n  #[error(transparent)]\n  YSync(RTProtocolError),\n\n  #[error(transparent)]\n  YAwareness(#[from] collab::core::awareness::Error),\n\n  #[error(\"failed to deserialize message: {0}\")]\n  DecodingError(#[from] yrs::encoding::read::Error),\n\n  #[error(\"Can not apply update for object:{0}\")]\n  YrsApplyUpdate(String),\n\n  #[error(transparent)]\n  SerdeError(#[from] serde_json::Error),\n\n  #[error(transparent)]\n  TokioTask(#[from] tokio::task::JoinError),\n\n  #[error(transparent)]\n  IO(#[from] std::io::Error),\n\n  #[error(\"Workspace id is not found\")]\n  NoWorkspaceId,\n\n  #[error(\"Missing updates\")]\n  MissUpdates {\n    state_vector_v1: Option<Vec<u8>>,\n    reason: MissUpdateReason,\n  },\n\n  #[error(\"Can not apply update\")]\n  CannotApplyUpdate,\n\n  #[error(\"{0}\")]\n  OverrideWithIncorrectData(String),\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n}\n\n#[derive(Debug)]\npub enum MissUpdateReason {\n  BroadcastSeqNotContinuous { current: u32, expected: u32 },\n  AckSeqAdvanceBroadcastSeq { ack_seq: u32, broadcast_seq: u32 },\n  ServerMissUpdates,\n  Other(String),\n}\n\nimpl Display for MissUpdateReason {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      MissUpdateReason::BroadcastSeqNotContinuous { current, expected } => {\n        write!(\n          f,\n          \"Broadcast sequence not continuous: current={}, expected={}\",\n          current, expected\n        )\n      },\n      MissUpdateReason::AckSeqAdvanceBroadcastSeq {\n        ack_seq,\n        broadcast_seq,\n      } => {\n        write!(\n          f,\n          \"Ack sequence advance broadcast sequence: ack_seq={}, broadcast_seq={}\",\n          ack_seq, broadcast_seq\n        )\n      },\n      MissUpdateReason::ServerMissUpdates => write!(f, \"Server miss updates\"),\n      MissUpdateReason::Other(reason) => write!(f, \"{}\", reason),\n    }\n  }\n}\n\nimpl From<RTProtocolError> for SyncError {\n  fn from(value: RTProtocolError) -> Self {\n    match value {\n      RTProtocolError::MissUpdates {\n        state_vector_v1,\n        reason,\n      } => Self::MissUpdates {\n        state_vector_v1,\n        reason: MissUpdateReason::Other(reason),\n      },\n      RTProtocolError::DecodingError(e) => Self::DecodingError(e),\n      RTProtocolError::YAwareness(e) => Self::YAwareness(e),\n      RTProtocolError::YrsApplyUpdate(e) => Self::YrsApplyUpdate(e),\n      RTProtocolError::Internal(e) => Self::Internal(e),\n      _ => Self::YSync(value),\n    }\n  }\n}\n\nimpl SyncError {\n  pub fn is_cannot_apply_update(&self) -> bool {\n    matches!(self, Self::YrsApplyUpdate(_))\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/mod.rs",
    "content": "mod collab_sink;\nmod collab_stream;\nmod error;\nmod plugin;\nmod sync_control;\n\npub use collab_rt_entity::{MsgId, ServerCollabMessage};\npub use collab_sink::*;\npub use error::*;\npub use plugin::*;\npub use sync_control::*;\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/plugin.rs",
    "content": "use std::future::Future;\nuse std::pin::Pin;\nuse std::sync::atomic::AtomicBool;\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\n\nuse anyhow::anyhow;\nuse collab::core::awareness::{AwarenessUpdate, Event};\nuse collab::core::collab_plugin::CollabPluginType;\nuse collab::core::collab_state::SyncState;\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::{Collab, CollabPlugin};\nuse futures_util::SinkExt;\nuse tokio_retry::strategy::FixedInterval;\nuse tokio_retry::{Action, Condition, RetryIf};\nuse tokio_stream::StreamExt;\nuse tracing::{error, trace};\nuse uuid::Uuid;\nuse yrs::updates::encoder::Encode;\n\nuse client_api_entity::{CollabObject, CollabType};\nuse collab_rt_entity::{ClientCollabMessage, ServerCollabMessage, UpdateSync};\nuse collab_rt_protocol::{Message, SyncMessage};\n\nuse crate::collab_sync::collab_stream::CollabRef;\nuse crate::collab_sync::{CollabSyncState, SinkConfig, SyncControl, SyncReason};\nuse crate::ws::{ConnectState, WSConnectStateReceiver};\n\npub struct SyncPlugin<Sink, Stream, Channel> {\n  object: SyncObject,\n  sync_queue: Arc<SyncControl<Sink, Stream>>,\n  // Used to keep the lifetime of the channel\n  #[allow(dead_code)]\n  channel: Option<Arc<Channel>>,\n  collab: CollabRef,\n  is_destroyed: Arc<AtomicBool>,\n}\n\nimpl<Sink, Stream, Channel> Drop for SyncPlugin<Sink, Stream, Channel> {\n  fn drop(&mut self) {\n    #[cfg(feature = \"sync_verbose_log\")]\n    trace!(\"Drop sync plugin: {}\", self.object.object_id);\n\n    // when the plugin is dropped, set the is_destroyed flag to true\n    self\n      .is_destroyed\n      .store(true, std::sync::atomic::Ordering::SeqCst);\n  }\n}\n\nimpl<E, Sink, Stream, Channel> SyncPlugin<Sink, Stream, Channel>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  Stream: StreamExt<Item = Result<ServerCollabMessage, E>> + Send + Sync + Unpin + 'static,\n  Channel: Send + Sync + 'static,\n{\n  #[allow(clippy::too_many_arguments)]\n  pub fn new(\n    origin: CollabOrigin,\n    object: SyncObject,\n    collab: CollabRef,\n    sink: Sink,\n    sink_config: SinkConfig,\n    stream: Stream,\n    channel: Option<Arc<Channel>>,\n    mut ws_connect_state: WSConnectStateReceiver,\n    periodic_sync: Option<Duration>,\n  ) -> Self {\n    let sync_queue = SyncControl::new(\n      object.clone(),\n      origin,\n      sink,\n      sink_config,\n      stream,\n      collab.clone(),\n      periodic_sync,\n    );\n\n    let mut sync_state_stream = sync_queue.subscribe_sync_state();\n    let sync_state_collab = collab.clone();\n    tokio::spawn(async move {\n      while let Ok(sink_state) = sync_state_stream.recv().await {\n        if let Some(collab) = sync_state_collab.upgrade() {\n          let sync_state = match sink_state {\n            CollabSyncState::Syncing => SyncState::Syncing,\n            _ => SyncState::SyncFinished,\n          };\n          let lock = collab.read().await;\n          lock.borrow().get_state().set_sync_state(sync_state);\n        } else {\n          break;\n        }\n      }\n    });\n\n    let sync_queue = Arc::new(sync_queue);\n    let weak_local_collab = collab.clone();\n    let weak_sync_queue = Arc::downgrade(&sync_queue);\n    tokio::spawn(async move {\n      while let Ok(connect_state) = ws_connect_state.recv().await {\n        match connect_state {\n          ConnectState::Connected => {\n            // If the websocket is connected, initialize a new init sync\n            if let (Some(local_collab), Some(sync_queue)) =\n              (weak_local_collab.upgrade(), weak_sync_queue.upgrade())\n            {\n              sync_queue.resume();\n              let lock = local_collab.read().await;\n              let _ = sync_queue.init_sync(lock.borrow(), SyncReason::NetworkResume);\n            } else {\n              break;\n            }\n          },\n          ConnectState::Unauthorized | ConnectState::Lost => {\n            if let Some(sync_queue) = weak_sync_queue.upgrade() {\n              // Stop sync if the websocket is unauthorized or disconnected\n              sync_queue.pause();\n            } else {\n              break;\n            }\n          },\n          _ => {},\n        }\n      }\n    });\n\n    Self {\n      sync_queue,\n      object,\n      channel,\n      collab,\n      is_destroyed: Arc::new(Default::default()),\n    }\n  }\n}\n\nimpl<E, Sink, Stream, Channel> CollabPlugin for SyncPlugin<Sink, Stream, Channel>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  Stream: StreamExt<Item = Result<ServerCollabMessage, E>> + Send + Sync + Unpin + 'static,\n  Channel: Send + Sync + 'static,\n{\n  fn plugin_type(&self) -> CollabPluginType {\n    CollabPluginType::CloudStorage\n  }\n\n  fn did_init(&self, _collab: &Collab, _object_id: &str) {\n    // Most of the time, it should be successful to queue init sync by 1st time.\n    let retry_strategy = FixedInterval::new(Duration::from_secs(1)).take(10);\n    let action = InitSyncAction {\n      sync_queue: Arc::downgrade(&self.sync_queue),\n      collab: self.collab.clone(),\n    };\n\n    let condition = InitSyncRetryCondition {\n      is_plugin_destroyed: self.is_destroyed.clone(),\n    };\n\n    tokio::spawn(async move {\n      if let Err(err) = RetryIf::spawn(retry_strategy, action, condition).await {\n        error!(\"Failed to start init sync: {}\", err);\n      }\n    });\n  }\n\n  fn receive_local_update(&self, origin: &CollabOrigin, _object_id: &str, update: &[u8]) {\n    let update = update.to_vec();\n    let payload = Message::Sync(SyncMessage::Update(update)).encode_v1();\n    self.sync_queue.queue_msg(|msg_id| {\n      let update_sync = UpdateSync::new(\n        origin.clone(),\n        self.object.object_id.to_string(),\n        payload,\n        msg_id,\n      );\n      ClientCollabMessage::new_update_sync(update_sync)\n    });\n  }\n\n  fn receive_local_state(\n    &self,\n    origin: &CollabOrigin,\n    object_id: &str,\n    _event: &Event,\n    update: &AwarenessUpdate,\n  ) {\n    let payload = Message::Awareness(update.encode_v1()).encode_v1();\n    self.sync_queue.queue_msg(|msg_id| {\n      let update_sync = UpdateSync::new(origin.clone(), object_id.to_string(), payload, msg_id);\n      if cfg!(feature = \"sync_verbose_log\") {\n        trace!(\"queue awareness: {:?}\", update);\n      }\n\n      ClientCollabMessage::new_awareness_sync(update_sync)\n    });\n  }\n\n  fn start_init_sync(&self) {\n    let collab = self.collab.clone();\n    let sync_queue = self.sync_queue.clone();\n\n    tokio::spawn(async move {\n      if let Some(collab) = collab.upgrade() {\n        let lock = collab.read().await;\n        if let Err(err) = sync_queue.init_sync(lock.borrow(), SyncReason::CollabInitialize) {\n          error!(\"Failed to start init sync: {}\", err);\n        }\n      }\n    });\n  }\n\n  fn destroy(&self) {\n    self\n      .is_destroyed\n      .store(true, std::sync::atomic::Ordering::SeqCst);\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct SyncObject {\n  pub object_id: Uuid,\n  pub workspace_id: Uuid,\n  pub collab_type: CollabType,\n  pub device_id: String,\n}\n\nimpl SyncObject {\n  pub fn new(\n    object_id: Uuid,\n    workspace_id: Uuid,\n    collab_type: CollabType,\n    device_id: &str,\n  ) -> Self {\n    Self {\n      object_id,\n      workspace_id,\n      collab_type,\n      device_id: device_id.to_string(),\n    }\n  }\n}\n\nimpl TryFrom<CollabObject> for SyncObject {\n  type Error = anyhow::Error;\n  fn try_from(collab_object: CollabObject) -> Result<Self, Self::Error> {\n    Ok(Self {\n      object_id: Uuid::parse_str(&collab_object.object_id)?,\n      workspace_id: Uuid::parse_str(&collab_object.workspace_id)?,\n      collab_type: collab_object.collab_type,\n      device_id: collab_object.device_id,\n    })\n  }\n}\n\npub(crate) struct InitSyncAction<Sink, Stream> {\n  sync_queue: Weak<SyncControl<Sink, Stream>>,\n  collab: CollabRef,\n}\n\nimpl<E, Sink, Stream> Action for InitSyncAction<Sink, Stream>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  Stream: StreamExt<Item = Result<ServerCollabMessage, E>> + Send + Sync + Unpin + 'static,\n{\n  type Future = Pin<Box<dyn Future<Output = Result<Self::Item, Self::Error>> + Send + Sync>>;\n  type Item = ();\n  type Error = anyhow::Error;\n\n  fn run(&mut self) -> Self::Future {\n    let weak_queue = self.sync_queue.clone();\n    let weak_collab = self.collab.clone();\n    Box::pin(async move {\n      if let (Some(queue), Some(collab)) = (weak_queue.upgrade(), weak_collab.upgrade()) {\n        if queue.did_queue_init_sync() {\n          return Ok(());\n        }\n        let lock = collab.read().await;\n        let collab = (*lock).borrow();\n        let is_queue = queue.init_sync(collab, SyncReason::CollabInitialize)?;\n        if is_queue {\n          return Ok(());\n        } else {\n          return Err(anyhow!(\"Failed to queue init sync\"));\n        }\n      }\n\n      // If the queue or collab is dropped, return Ok to stop retrying.\n      Ok(())\n    })\n  }\n}\n\npub(crate) struct InitSyncRetryCondition {\n  is_plugin_destroyed: Arc<AtomicBool>,\n}\nimpl Condition<anyhow::Error> for InitSyncRetryCondition {\n  fn should_retry(&mut self, _error: &anyhow::Error) -> bool {\n    // Only retry if the plugin is not destroyed\n    !self\n      .is_plugin_destroyed\n      .load(std::sync::atomic::Ordering::SeqCst)\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/collab_sync/sync_control.rs",
    "content": "use std::fmt::Display;\nuse std::ops::Deref;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse collab::core::awareness::Awareness;\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse futures_util::{SinkExt, StreamExt};\nuse tokio::sync::{broadcast, watch};\nuse tracing::{error, info, instrument, trace};\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::{Encode, Encoder, EncoderV1};\nuse yrs::{ReadTxn, StateVector};\n\nuse collab_rt_entity::{ClientCollabMessage, InitSync, ServerCollabMessage, UpdateSync};\nuse collab_rt_protocol::{ClientSyncProtocol, CollabSyncProtocol, Message, SyncMessage};\n\nuse crate::collab_sync::collab_stream::{CollabRef, ObserveCollab};\nuse crate::collab_sync::{\n  CollabSink, CollabSinkRunner, CollabSyncState, MissUpdateReason, SinkSignal, SyncError,\n  SyncObject,\n};\n\npub const DEFAULT_SYNC_TIMEOUT: u64 = 10;\n\npub struct SyncControl<Sink, Stream> {\n  object: SyncObject,\n  pub(crate) origin: CollabOrigin,\n  /// The [CollabSink] is used to send the updates to the remote. It will send the current\n  /// update periodically if the timeout is reached or it will send the next update if\n  /// it receive previous ack from the remote.\n  sink: Arc<CollabSink<Sink>>,\n  /// The [ObserveCollab] will be spawned in a separate task It continuously receive\n  /// the updates from the remote.\n  #[allow(dead_code)]\n  observe_collab: ObserveCollab<Sink, Stream>,\n  sync_state_tx: broadcast::Sender<CollabSyncState>,\n}\n\nimpl<Sink, Stream> Drop for SyncControl<Sink, Stream> {\n  fn drop(&mut self) {\n    #[cfg(feature = \"sync_verbose_log\")]\n    trace!(\"Drop SyncQueue {}\", self.object.object_id);\n  }\n}\n\nimpl<E, Sink, Stream> SyncControl<Sink, Stream>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n  Stream: StreamExt<Item = Result<ServerCollabMessage, E>> + Send + Sync + Unpin + 'static,\n{\n  #[allow(clippy::too_many_arguments)]\n  pub fn new(\n    object: SyncObject,\n    origin: CollabOrigin,\n    sink: Sink,\n    sink_config: SinkConfig,\n    stream: Stream,\n    collab: CollabRef,\n    periodic_sync: Option<Duration>,\n  ) -> Self {\n    let (notifier, notifier_rx) = watch::channel(SinkSignal::Proceed);\n    let (sync_state_tx, _) = broadcast::channel(10);\n    debug_assert!(origin.client_user_id().is_some());\n\n    // Create the sink and start the sink runner.\n    let sink = Arc::new(CollabSink::new(\n      origin.client_user_id().unwrap_or(0),\n      object.clone(),\n      sink,\n      notifier,\n      sync_state_tx.clone(),\n      sink_config,\n    ));\n    tokio::spawn(CollabSinkRunner::run(Arc::downgrade(&sink), notifier_rx));\n\n    // Create the observe collab stream.\n    let stream = ObserveCollab::new(\n      origin.clone(),\n      object.clone(),\n      stream,\n      collab.clone(),\n      Arc::downgrade(&sink),\n      periodic_sync,\n    );\n\n    Self {\n      object,\n      origin,\n      sink,\n      observe_collab: stream,\n      sync_state_tx,\n    }\n  }\n\n  pub fn pause(&self) {\n    info!(\"pause {} sync\", self.object.object_id);\n    self.sink.pause();\n  }\n\n  pub fn resume(&self) {\n    info!(\"resume {} sync\", self.object.object_id);\n    self.sink.resume();\n  }\n\n  pub fn subscribe_sync_state(&self) -> broadcast::Receiver<CollabSyncState> {\n    self.sync_state_tx.subscribe()\n  }\n\n  /// Returns bool indicating whether the init sync is queued.\n  pub fn init_sync(\n    &self,\n    collab: &collab::preclude::Collab,\n    reason: SyncReason,\n  ) -> Result<bool, SyncError> {\n    start_sync(\n      self.origin.clone(),\n      &self.object,\n      collab,\n      &self.sink,\n      reason,\n    )\n  }\n}\n\npub enum SyncReason {\n  CollabInitialize,\n  ServerMissUpdates {\n    state_vector_v1: Vec<u8>,\n    reason: MissUpdateReason,\n  },\n  ClientMissUpdates {\n    reason: MissUpdateReason,\n  },\n  ServerCannotApplyUpdate,\n  NetworkResume,\n}\n\nimpl Display for SyncReason {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      SyncReason::CollabInitialize => write!(f, \"CollabInitialize\"),\n      SyncReason::ServerMissUpdates { reason, .. } => write!(f, \"ServerMissUpdates: {}\", reason),\n      SyncReason::ClientMissUpdates { reason } => write!(f, \"ClientMissUpdates: {}\", reason),\n      SyncReason::ServerCannotApplyUpdate => write!(f, \"ServerCannotApplyUpdate\"),\n      SyncReason::NetworkResume => write!(f, \"NetworkResume\"),\n    }\n  }\n}\n\nfn gen_sync_state<P: CollabSyncProtocol>(\n  awareness: &Awareness,\n  protocol: &P,\n) -> Result<Vec<u8>, SyncError> {\n  let mut encoder = EncoderV1::new();\n  protocol.start(awareness, &mut encoder)?;\n  Ok(encoder.to_vec())\n}\n\nfn gen_missing_updates(collab: &Collab, sv: StateVector) -> Result<Vec<u8>, SyncError> {\n  let update = {\n    let txn = collab.transact();\n    txn.encode_state_as_update_v1(&sv)\n  };\n\n  let mut encoder = EncoderV1::new();\n  Message::Sync(SyncMessage::Update(update)).encode(&mut encoder);\n  Ok(encoder.to_vec())\n}\n\n#[instrument(level = \"trace\", skip_all)]\npub fn start_sync<E, Sink>(\n  origin: CollabOrigin,\n  sync_object: &SyncObject,\n  collab: &Collab,\n  sink: &Arc<CollabSink<Sink>>,\n  reason: SyncReason,\n) -> Result<bool, SyncError>\nwhere\n  E: Into<anyhow::Error> + Send + Sync + 'static,\n  Sink: SinkExt<Vec<ClientCollabMessage>, Error = E> + Send + Sync + Unpin + 'static,\n{\n  if let Err(err) = sync_object.collab_type.validate_require_data(collab) {\n    return Err(SyncError::Internal(err.into()));\n  }\n\n  match reason {\n    SyncReason::ClientMissUpdates { reason } => {\n      if !sink.should_queue_init_sync() {\n        return Ok(false);\n      }\n\n      tracing::debug!(\n        \"🔥{} restart sync due to missing update, reason:{}\",\n        &sync_object.object_id,\n        reason\n      );\n      let awareness = collab.get_awareness();\n      let payload = gen_sync_state(awareness, &ClientSyncProtocol)?;\n      sink.queue_init_sync(|msg_id| {\n        let init_sync = InitSync::new(\n          origin,\n          sync_object.object_id.to_string(),\n          sync_object.collab_type,\n          sync_object.workspace_id.to_string(),\n          msg_id,\n          payload,\n        );\n        ClientCollabMessage::new_init_sync(init_sync)\n      });\n    },\n    SyncReason::ServerMissUpdates {\n      state_vector_v1,\n      reason,\n    } => match StateVector::decode_v1(&state_vector_v1) {\n      Ok(sv) => {\n        trace!(\"🔥{} start sync, reason:{}\", &sync_object.object_id, reason);\n        let update = gen_missing_updates(collab, sv)?;\n        sink.queue_msg(|msg_id| {\n          let update_sync = UpdateSync::new(\n            origin.clone(),\n            sync_object.object_id.to_string(),\n            update,\n            msg_id,\n          );\n          ClientCollabMessage::new_update_sync(update_sync)\n        });\n      },\n      Err(err) => error!(\"fail to decode server state vector: {}\", err),\n    },\n    SyncReason::CollabInitialize\n    | SyncReason::ServerCannotApplyUpdate\n    | SyncReason::NetworkResume => {\n      tracing::debug!(\n        \"🔥{} resume network, reason: {}\",\n        &sync_object.object_id,\n        reason\n      );\n      let awareness = collab.get_awareness();\n      let payload = gen_sync_state(awareness, &ClientSyncProtocol)?;\n      sink.queue_init_sync(|msg_id| {\n        let init_sync = InitSync::new(\n          origin,\n          sync_object.object_id.to_string(),\n          sync_object.collab_type,\n          sync_object.workspace_id.to_string(),\n          msg_id,\n          payload,\n        );\n        ClientCollabMessage::new_init_sync(init_sync)\n      });\n    },\n  };\n\n  Ok(true)\n}\n\nimpl<Sink, Stream> Deref for SyncControl<Sink, Stream> {\n  type Target = Arc<CollabSink<Sink>>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.sink\n  }\n}\n\npub struct SinkConfig {\n  /// `timeout` is the time to wait for the remote to ack the message. If the remote\n  /// does not ack the message in time, the message will be sent again.\n  pub send_timeout: Duration,\n  /// `maximum_payload_size` is the maximum size of the messages to be merged.\n  pub maximum_payload_size: usize,\n}\n\nimpl SinkConfig {\n  pub fn new() -> Self {\n    Self::default()\n  }\n  pub fn send_timeout(mut self, secs: u64) -> Self {\n    self.send_timeout = Duration::from_secs(secs);\n    self\n  }\n}\n\nimpl Default for SinkConfig {\n  fn default() -> Self {\n    Self {\n      send_timeout: Duration::from_secs(DEFAULT_SYNC_TIMEOUT),\n      maximum_payload_size: 1024 * 10,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http.rs",
    "content": "use crate::notify::{ClientToken, TokenStateReceiver};\nuse app_error::AppError;\nuse app_error::ErrorCode;\nuse client_api_entity::auth_dto::DeleteUserQuery;\nuse client_api_entity::server_info_dto::ServerInfoResponseItem;\nuse client_api_entity::workspace_dto::FavoriteSectionItems;\nuse client_api_entity::workspace_dto::RecentSectionItems;\nuse client_api_entity::workspace_dto::TrashSectionItems;\nuse client_api_entity::workspace_dto::{FolderView, QueryWorkspaceFolder, QueryWorkspaceParam};\nuse client_api_entity::AuthProvider;\nuse client_api_entity::GetInvitationCodeInfoQuery;\nuse client_api_entity::InvitationCodeInfo;\nuse client_api_entity::InvitedWorkspace;\nuse client_api_entity::JoinWorkspaceByInviteCodeParams;\nuse client_api_entity::WorkspaceInviteCodeParams;\nuse client_api_entity::WorkspaceInviteToken as WorkspaceInviteCode;\nuse gotrue::grant::PasswordGrant;\nuse gotrue::grant::{Grant, RefreshTokenGrant};\nuse gotrue::params::{AdminUserParams, GenerateLinkParams};\nuse gotrue::params::{MagicLinkParams, VerifyParams, VerifyType};\nuse reqwest::StatusCode;\nuse shared_entity::dto::workspace_dto::{CreateWorkspaceParam, PatchWorkspaceParam};\nuse std::borrow::Cow;\nuse std::fmt::{Display, Formatter};\n#[cfg(feature = \"enable_brotli\")]\nuse std::io::Read;\n\nuse parking_lot::RwLock;\nuse reqwest::Method;\nuse reqwest::RequestBuilder;\n\nuse crate::retry::{RefreshTokenAction, RefreshTokenRetryCondition};\nuse crate::ws::ConnectInfo;\nuse anyhow::anyhow;\nuse client_api_entity::SignUpResponse::{Authenticated, NotAuthenticated};\nuse client_api_entity::{AFUserProfile, AFUserWorkspaceInfo, AFWorkspace};\nuse client_api_entity::{GotrueTokenResponse, UpdateGotrueUserParams, User};\nuse semver::Version;\nuse shared_entity::dto::auth_dto::UpdateUserParams;\nuse shared_entity::dto::auth_dto::{SignInPasswordResponse, SignInTokenResponse};\nuse shared_entity::dto::workspace_dto::WorkspaceSpaceUsage;\nuse shared_entity::response::{AppResponse, AppResponseError};\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio_retry::strategy::FixedInterval;\nuse tokio_retry::RetryIf;\nuse tracing::{debug, error, event, info, instrument, trace, warn};\nuse url::Url;\nuse uuid::Uuid;\n\npub const X_COMPRESSION_TYPE: &str = \"X-Compression-Type\";\npub const X_COMPRESSION_BUFFER_SIZE: &str = \"X-Compression-Buffer-Size\";\npub const X_COMPRESSION_TYPE_BROTLI: &str = \"brotli\";\n\n#[derive(Clone)]\npub struct ClientConfiguration {\n  /// Lower Levels (0-4): Faster compression and decompression speeds but lower compression ratios. Suitable for scenarios where speed is more critical than reducing data size.\n  /// Medium Levels (5-9): A balance between compression ratio and speed. These levels are generally good for a mix of performance and efficiency.\n  /// Higher Levels (10-11): The highest compression ratios, but significantly slower and more resource-intensive. These are typically used in scenarios where reducing data size is paramount and resource usage is a secondary concern, such as for static content compression in web servers.\n  pub(crate) compression_quality: u32,\n  /// A larger buffer size means more data is compressed in a single operation, which can lead to better compression ratios\n  /// since Brotli has more data to analyze for patterns and repetitions.\n  pub(crate) compression_buffer_size: usize,\n}\n\nimpl ClientConfiguration {\n  pub fn with_compression_buffer_size(mut self, compression_buffer_size: usize) -> Self {\n    self.compression_buffer_size = compression_buffer_size;\n    self\n  }\n\n  pub fn with_compression_quality(mut self, compression_quality: u32) -> Self {\n    self.compression_quality = if compression_quality > 11 {\n      warn!(\"compression_quality is larger than 11, set it to 11\");\n      11\n    } else {\n      compression_quality\n    };\n    self\n  }\n}\n\nimpl Default for ClientConfiguration {\n  fn default() -> Self {\n    Self {\n      compression_quality: 8,\n      compression_buffer_size: 10240,\n    }\n  }\n}\n\n/// `Client` is responsible for managing communication with the GoTrue API and cloud storage.\n///\n/// It provides methods to perform actions like signing in, signing out, refreshing tokens,\n/// and interacting with file storage and collaboration objects.\n///\n/// # Fields\n/// - `cloud_client`: A `reqwest::Client` used for HTTP requests to the cloud.\n/// - `gotrue_client`: A `gotrue::api::Client` used for interacting with the GoTrue API.\n/// - `base_url`: The base URL for API requests.\n/// - `ws_addr`: The WebSocket address for real-time communication.\n/// - `token`: An `Arc<RwLock<ClientToken>>` managing the client's authentication token.\n///\n#[derive(Clone)]\npub struct Client {\n  pub cloud_client: reqwest::Client,\n  pub(crate) gotrue_client: gotrue::api::Client,\n  pub base_url: String,\n  pub ws_addr: String,\n  pub device_id: String,\n  pub client_version: Version,\n  pub(crate) token: Arc<RwLock<ClientToken>>,\n  pub(crate) is_refreshing_token: Arc<AtomicBool>,\n  pub(crate) refresh_ret_txs: Arc<RwLock<Vec<RefreshTokenSender>>>,\n  pub(crate) config: ClientConfiguration,\n  pub(crate) ai_model: Arc<RwLock<String>>,\n}\n\npub(crate) type RefreshTokenSender = tokio::sync::oneshot::Sender<Result<(), AppResponseError>>;\n\n/// Hardcoded schema in the frontend application. Do not change this value.\nconst DESKTOP_CALLBACK_URL: &str = \"appflowy-flutter://login-callback\";\n\nimpl Client {\n  /// Constructs a new `Client` instance.\n  ///\n  /// # Parameters\n  /// - `base_url`: The base URL for API requests.\n  /// - `ws_addr`: The WebSocket address for real-time communication.\n  /// - `gotrue_url`: The URL for the GoTrue API.\n  pub fn new(\n    base_url: &str,\n    ws_addr: &str,\n    gotrue_url: &str,\n    device_id: &str,\n    config: ClientConfiguration,\n    client_id: &str,\n  ) -> Self {\n    let reqwest_client = reqwest::Client::new();\n    let client_version = Version::parse(client_id).unwrap_or_else(|_| Version::new(0, 6, 7));\n\n    let min_version = Version::new(0, 6, 7);\n    let max_version = Version::new(1, 0, 0);\n    // Log warnings in debug mode if the version is out of the valid range\n    if cfg!(debug_assertions) {\n      if client_version < min_version {\n        error!(\n          \"Client version is less than {}, setting it to {}\",\n          min_version, min_version\n        );\n      } else if client_version >= max_version {\n        error!(\n          \"Client version is greater than or equal to {}, setting it to {}\",\n          max_version, min_version\n        );\n      }\n    }\n\n    let client_version = client_version.clamp(min_version, max_version);\n    #[cfg(debug_assertions)]\n    {\n      let feature_flags = [\n        (\"sync_verbose_log\", cfg!(feature = \"sync_verbose_log\")),\n        (\"enable_brotli\", cfg!(feature = \"enable_brotli\")),\n        // Add more features here as needed.\n      ];\n\n      let enabled_features: Vec<&str> = feature_flags\n        .iter()\n        .filter_map(|&(name, enabled)| if enabled { Some(name) } else { None })\n        .collect();\n\n      trace!(\n        \"Client version: {}, features: {:?}\",\n        client_version,\n        enabled_features\n      );\n    }\n\n    let ai_model = Arc::new(RwLock::new(\"Auto\".to_string()));\n\n    Self {\n      base_url: base_url.to_string(),\n      ws_addr: ws_addr.to_string(),\n      cloud_client: reqwest_client.clone(),\n      gotrue_client: gotrue::api::Client::new(reqwest_client, gotrue_url),\n      token: Arc::new(RwLock::new(ClientToken::new())),\n      is_refreshing_token: Default::default(),\n      refresh_ret_txs: Default::default(),\n      config,\n      device_id: device_id.to_string(),\n      client_version,\n      ai_model,\n    }\n  }\n\n  pub fn base_url(&self) -> &str {\n    &self.base_url\n  }\n\n  pub fn ws_addr(&self) -> &str {\n    &self.ws_addr\n  }\n\n  pub fn gotrue_url(&self) -> &str {\n    &self.gotrue_client.base_url\n  }\n\n  pub fn set_ai_model(&self, model: String) {\n    info!(\"using ai model: {:?}\", model);\n    *self.ai_model.write() = model;\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub fn restore_token(&self, token: &str) -> Result<(), AppResponseError> {\n    match serde_json::from_str::<GotrueTokenResponse>(token) {\n      Ok(token) => {\n        self.token.write().set(token);\n        Ok(())\n      },\n      Err(err) => {\n        error!(\"fail to deserialize token:{}, error:{}\", token, err);\n        Err(err.into())\n      },\n    }\n  }\n\n  /// Retrieves the string representation of the [GotrueTokenResponse]. The returned value can be\n  /// saved to the client application's local storage and used to restore the client's authentication\n  ///\n  /// This function attempts to acquire a read lock on `self.token` and retrieves the\n  /// string representation of the access token. If the lock cannot be acquired or\n  /// the token is not present, an error is returned.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub fn get_token_str(&self) -> Result<String, AppResponseError> {\n    let token_str = self\n      .token\n      .read()\n      .try_get()\n      .map_err(|err| AppResponseError::from(AppError::OAuthError(err.to_string())))?;\n    Ok(token_str)\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub fn get_token(&self) -> Result<GotrueTokenResponse, AppResponseError> {\n    let guard = self.token.read();\n    let resp = guard\n      .as_ref()\n      .ok_or_else(|| AppResponseError::new(ErrorCode::UserUnAuthorized, \"user is not logged in\"))?;\n    Ok(resp.clone())\n  }\n\n  pub fn get_access_token(&self) -> Result<String, AppResponseError> {\n    self\n      .token\n      .read()\n      .as_ref()\n      .map(|v| v.access_token.clone())\n      .ok_or_else(|| AppResponseError::new(ErrorCode::UserUnAuthorized, \"user is not logged in\"))\n  }\n\n  pub fn subscribe_token_state(&self) -> TokenStateReceiver {\n    self.token.read().subscribe()\n  }\n\n  #[instrument(skip_all, err)]\n  pub async fn sign_in_password(\n    &self,\n    email: &str,\n    password: &str,\n  ) -> Result<SignInPasswordResponse, AppResponseError> {\n    let response = self\n      .gotrue_client\n      .token(&Grant::Password(PasswordGrant {\n        email: email.to_owned(),\n        password: password.to_owned(),\n      }))\n      .await?;\n    let is_new = self.verify_token_cloud(&response.access_token).await?;\n    self.token.write().set(response.clone());\n    Ok(SignInPasswordResponse {\n      gotrue_response: response,\n      is_new,\n    })\n  }\n\n  /// Sign in with magic link\n  ///\n  /// User will receive an email with a magic link to sign in.\n  /// The redirect_to parameter is optional. If provided, the user will be redirected to the specified URL after signing in.\n  /// If not, the user will be redirected to the appflowy-flutter:// by default\n  ///\n  /// The redirect_to should be the scheme of the app, e.g., appflowy-flutter://\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn sign_in_with_magic_link(\n    &self,\n    email: &str,\n    redirect_to: Option<String>,\n  ) -> Result<(), AppResponseError> {\n    self\n      .gotrue_client\n      .magic_link(\n        &MagicLinkParams {\n          email: email.to_owned(),\n          ..Default::default()\n        },\n        redirect_to,\n      )\n      .await?;\n    Ok(())\n  }\n\n  /// Sign in with recovery token\n  ///\n  /// User will receive an email with a recovery code to sign in after clicking Forget Password.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn sign_in_with_recovery_code(\n    &self,\n    email: &str,\n    passcode: &str,\n  ) -> Result<GotrueTokenResponse, AppResponseError> {\n    let response = self\n      .gotrue_client\n      .verify(&VerifyParams {\n        email: email.to_owned(),\n        token: passcode.to_owned(),\n        type_: VerifyType::Recovery,\n      })\n      .await?;\n    let _ = self.verify_token_cloud(&response.access_token).await?;\n    self.token.write().set(response.clone());\n    Ok(response)\n  }\n\n  /// Sign in with passcode (OTP)\n  ///\n  /// User will receive an email with a passcode to sign in.\n  ///\n  /// For more information, please refer to the sign_in_with_magic_link function.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn sign_in_with_passcode(\n    &self,\n    email: &str,\n    passcode: &str,\n  ) -> Result<GotrueTokenResponse, AppResponseError> {\n    let response = self\n      .gotrue_client\n      .verify(&VerifyParams {\n        email: email.to_owned(),\n        token: passcode.to_owned(),\n        type_: VerifyType::MagicLink,\n      })\n      .await?;\n    let _ = self.verify_token_cloud(&response.access_token).await?;\n    self.token.write().set(response.clone());\n    Ok(response)\n  }\n\n  /// Attempts to sign in using a URL, extracting refresh_token from the URL.\n  /// It looks like, e.g., `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`.\n  ///\n  /// return a bool indicating if the user is new\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn sign_in_with_url(&self, url: &str) -> Result<bool, AppResponseError> {\n    let parsed = Url::parse(url)?;\n    let key_value_pairs = parsed\n      .fragment()\n      .ok_or(url_missing_param(\"fragment\"))?\n      .split('&');\n\n    let mut refresh_token: Option<&str> = None;\n    let mut provider_token: Option<String> = None;\n    let mut provider_refresh_token: Option<String> = None;\n    for param in key_value_pairs {\n      match param.split_once('=') {\n        Some(pair) => {\n          let (k, v) = pair;\n          if k == \"refresh_token\" {\n            refresh_token = Some(v);\n          } else if k == \"provider_token\" {\n            provider_token = Some(v.to_string());\n          } else if k == \"provider_refresh_token\" {\n            provider_refresh_token = Some(v.to_string());\n          }\n        },\n        None => warn!(\"param is not in key=value format: {}\", param),\n      }\n    }\n    let refresh_token = refresh_token.ok_or(url_missing_param(\"refresh_token\"))?;\n\n    let mut new_token = self\n      .gotrue_client\n      .token(&Grant::RefreshToken(RefreshTokenGrant {\n        refresh_token: refresh_token.to_owned(),\n      }))\n      .await?;\n\n    // refresh endpoint does not return provider token\n    // so we need to set it manually to preserve this information\n    new_token.provider_access_token = provider_token;\n    new_token.provider_refresh_token = provider_refresh_token;\n\n    let (_user, new) = self.verify_token(&new_token.access_token).await?;\n    self.token.write().set(new_token);\n    Ok(new)\n  }\n\n  /// Returns an OAuth URL by constructing the authorization URL for the specified provider.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn generate_oauth_url_with_provider(\n    &self,\n    provider: &AuthProvider,\n  ) -> Result<String, AppResponseError> {\n    self\n      .generate_url_with_provider_and_redirect_to(provider, None)\n      .await\n  }\n\n  /// Returns an OAuth URL by constructing the authorization URL for the specified provider and redirecting to the specified URL.\n  ///\n  /// This asynchronous function communicates with the GoTrue client to retrieve settings and\n  /// validate the availability of the specified OAuth provider. If the provider is available,\n  /// it constructs and returns the OAuth URL. When the user opens the OAuth URL, it redirects to\n  /// the corresponding provider's OAuth web page. After the user is authenticated, the browser will open\n  /// a deep link to the AppFlowy app (iOS, macOS, etc.), which will call [Client::sign_in_with_url] to sign in.\n  ///\n  /// For example, the OAuth URL on Google looks like `https://appflowy.io/authorize?provider=google`.\n  /// The deep link looks like `appflowy-flutter://#access_token=...&expires_in=3600&provider_token=...&refresh_token=...&token_type=bearer`.\n  ///\n  ///\n  /// # Parameters\n  /// - `provider`: A reference to an `OAuthProvider` indicating which OAuth provider to use for login.\n  /// - `redirect_to`: An optional `String` containing the URL to redirect to after the user is authenticated.\n  ///\n  /// # Returns\n  /// - `Ok(String)`: A `String` containing the constructed authorization URL if the specified provider is available.\n  /// - `Err(AppResponseError)`: An `AppResponseError` indicating either the OAuth provider is invalid or other issues occurred while fetching settings.\n  ///\n  pub async fn generate_url_with_provider_and_redirect_to(\n    &self,\n    provider: &AuthProvider,\n    redirect_to: Option<String>,\n  ) -> Result<String, AppResponseError> {\n    let settings = self.gotrue_client.settings().await?;\n    if !settings.external.has_provider(provider) {\n      return Err(AppError::InvalidOAuthProvider(provider.as_str().to_owned()).into());\n    }\n\n    let url = format!(\"{}/authorize\", self.gotrue_client.base_url,);\n\n    let mut url = Url::parse(&url)?;\n    url\n      .query_pairs_mut()\n      .append_pair(\"provider\", provider.as_str())\n      .append_pair(\n        \"redirect_to\",\n        redirect_to\n          .unwrap_or_else(|| DESKTOP_CALLBACK_URL.to_string())\n          .as_str(),\n      );\n\n    if let AuthProvider::Google = provider {\n      url\n        .query_pairs_mut()\n          // In many cases, especially for server-side applications or mobile apps that might need to\n          // interact with Google services on behalf of the user without the user being actively\n          // engaged, access_type=offline is preferred to ensure long-term access.\n        .append_pair(\"access_type\", \"offline\")\n          // In Google OAuth2.0, the prompt parameter is used to control the OAuth2.0 flow's behavior.\n          // It determines if the user is re-prompted for authentication and/or consent.\n          // 1. none: The authorization server does not display any authentication or consent user interface pages.\n          // 2. consent: The authorization server prompts the user for consent before returning information to the client\n          // 3. select_account: The authorization server prompts the user to select a user account.\n        .append_pair(\"prompt\", \"consent\");\n    }\n\n    Ok(url.to_string())\n  }\n\n  /// Generates a sign action link for the specified email address.\n  /// This is only applicable if user token is with admin privilege.\n  /// This action link is used on web browser to sign in. When user then click the action link in the browser,\n  /// which calls gotrue authentication server, which then redirects to the appflowy-flutter:// with the authentication token.\n  ///\n  /// [Self::extract_sign_in_url] simulates the browser behavior to extract the sign in url.\n  ///\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn generate_sign_in_action_link(\n    &self,\n    email: &str,\n  ) -> Result<String, AppResponseError> {\n    let admin_user_params: GenerateLinkParams = GenerateLinkParams {\n      email: email.to_string(),\n      ..Default::default()\n    };\n\n    let link_resp = self\n      .gotrue_client\n      .admin_generate_link(&self.access_token()?, &admin_user_params)\n      .await?;\n    assert_eq!(link_resp.email, email);\n\n    Ok(link_resp.action_link)\n  }\n\n  #[cfg(feature = \"test_util\")]\n  /// Used to extract the sign in url from the action link\n  /// Only expose this method for testing\n  pub async fn extract_sign_in_url(&self, action_link: &str) -> Result<String, AppResponseError> {\n    let resp = reqwest::Client::new().get(action_link).send().await?;\n    let html = resp.text().await.unwrap();\n\n    trace!(\"action_link:{}, html: {}\", action_link, html);\n    let fragment = scraper::Html::parse_fragment(&html);\n    let selector = scraper::Selector::parse(\"a\").unwrap();\n    let sign_in_url = fragment\n      .select(&selector)\n      .next()\n      .unwrap()\n      .value()\n      .attr(\"href\")\n      .unwrap()\n      .to_string();\n\n    Ok(sign_in_url)\n  }\n\n  #[inline]\n  #[instrument(level = \"info\", skip_all, err)]\n  async fn verify_token(&self, access_token: &str) -> Result<(User, bool), AppResponseError> {\n    let user = self.gotrue_client.user_info(access_token).await?;\n    let is_new = self.verify_token_cloud(access_token).await?;\n    Ok((user, is_new))\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  #[inline]\n  async fn verify_token_cloud(&self, access_token: &str) -> Result<bool, AppResponseError> {\n    let url = format!(\"{}/api/user/verify/{}\", self.base_url, access_token);\n    let resp = self.cloud_client.get(&url).send().await?;\n    let sign_in_resp: SignInTokenResponse =\n      process_response_data::<SignInTokenResponse>(resp).await?;\n    Ok(sign_in_resp.is_new)\n  }\n\n  // Invites another user by sending a magic link to the user's email address.\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> {\n    self\n      .gotrue_client\n      .magic_link(\n        &MagicLinkParams {\n          email: email.to_owned(),\n          ..Default::default()\n        },\n        None,\n      )\n      .await?;\n    Ok(())\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn create_user(&self, email: &str, password: &str) -> Result<User, AppResponseError> {\n    Ok(\n      self\n        .gotrue_client\n        .admin_add_user(\n          &self.access_token()?,\n          &AdminUserParams {\n            email: email.to_owned(),\n            password: Some(password.to_owned()),\n            email_confirm: true,\n            ..Default::default()\n          },\n        )\n        .await?,\n    )\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn create_email_verified_user(\n    &self,\n    email: &str,\n    password: &str,\n  ) -> Result<User, AppResponseError> {\n    Ok(\n      self\n        .gotrue_client\n        .admin_add_user(\n          &self.access_token()?,\n          &AdminUserParams {\n            email: email.to_owned(),\n            password: Some(password.to_owned()),\n            email_confirm: true,\n            ..Default::default()\n          },\n        )\n        .await?,\n    )\n  }\n\n  // filter is postgre sql like filter\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn admin_list_users(\n    &self,\n    filter: Option<&str>,\n  ) -> Result<Vec<User>, AppResponseError> {\n    let user = self\n      .gotrue_client\n      .admin_list_user(&self.access_token()?, filter)\n      .await?;\n    Ok(user.users)\n  }\n\n  /// Only expose this method for testing\n  pub fn token(&self) -> Arc<RwLock<ClientToken>> {\n    self.token.clone()\n  }\n\n  /// Retrieves the expiration timestamp of the current token.\n  ///\n  /// This function attempts to read the current token and, if successful, returns the expiration timestamp.\n  ///\n  /// # Returns\n  /// - `Ok(i64)`: An `i64` representing the expiration timestamp of the token in seconds.\n  /// - `Err(AppError)`: An `AppError` indicating either an inability to read the token or that the user is not logged in.\n  ///\n  #[inline]\n  pub fn token_expires_at(&self) -> Result<i64, AppResponseError> {\n    match &self.token.try_read() {\n      None => Err(AppError::Unhandled(\"Failed to read token\".to_string()).into()),\n      Some(token) => Ok(\n        token\n          .as_ref()\n          .ok_or(AppResponseError::from(AppError::NotLoggedIn(\n            \"token is empty\".to_string(),\n          )))?\n          .expires_at,\n      ),\n    }\n  }\n\n  /// Retrieves the access token string.\n  ///\n  /// This function attempts to read the current token and, if successful, returns the access token string.\n  ///\n  /// # Returns\n  /// - `Ok(String)`: A `String` containing the access token.\n  /// - `Err(AppResponseError)`: An `AppResponseError` indicating either an inability to read the token or that the user is not logged in.\n  ///\n  pub fn access_token(&self) -> Result<String, AppResponseError> {\n    match &self.token.try_read_for(Duration::from_secs(2)) {\n      None => Err(AppError::Unhandled(\"Failed to read token\".to_string()).into()),\n      Some(token) => {\n        let access_token = token\n          .as_ref()\n          .ok_or(AppResponseError::from(AppError::NotLoggedIn(\n            \"fail to get access token. Token is empty\".to_string(),\n          )))?\n          .access_token\n          .clone();\n\n        if access_token.is_empty() {\n          error!(\"Unexpected empty access token\");\n        }\n        Ok(access_token)\n      },\n    }\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_profile(&self) -> Result<AFUserProfile, AppResponseError> {\n    let url = format!(\"{}/api/user/profile\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFUserProfile>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_user_workspace_info(&self) -> Result<AFUserWorkspaceInfo, AppResponseError> {\n    let url = format!(\"{}/api/user/workspace\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFUserWorkspaceInfo>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn delete_workspace(&self, workspace_id: &Uuid) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn create_workspace(\n    &self,\n    params: CreateWorkspaceParam,\n  ) -> Result<AFWorkspace, AppResponseError> {\n    let url = format!(\"{}/api/workspace\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<AFWorkspace>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn patch_workspace(&self, params: PatchWorkspaceParam) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::PATCH, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_workspaces(&self) -> Result<Vec<AFWorkspace>, AppResponseError> {\n    self\n      .get_workspaces_opt(QueryWorkspaceParam::default())\n      .await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspaces_opt(\n    &self,\n    param: QueryWorkspaceParam,\n  ) -> Result<Vec<AFWorkspace>, AppResponseError> {\n    let url = format!(\"{}/api/workspace\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&param)\n      .send()\n      .await?;\n    process_response_data::<Vec<AFWorkspace>>(resp).await\n  }\n\n  /// List out the views in the workspace recursively.\n  /// The depth parameter specifies the depth of the folder view tree to return(default: 1).\n  /// e.g., depth=1 will return only up to `Shared` and `PrivateSpace`\n  /// depth=2 will return up to `mydoc1`, `mydoc2`, `mydoc3`, `mydoc4`\n  ///\n  /// . MyWorkspace\n  /// ├── Shared\n  /// │   ├── mydoc1\n  /// │   └── mydoc2\n  /// └── PrivateSpace\n  ///     ├── mydoc3\n  ///     └── mydoc4\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_folder(\n    &self,\n    workspace_id: &Uuid,\n    depth: Option<u32>,\n    root_view_id: Option<Uuid>,\n  ) -> Result<FolderView, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/folder\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&QueryWorkspaceFolder {\n        depth,\n        root_view_id,\n      })\n      .send()\n      .await?;\n    process_response_data::<FolderView>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn open_workspace(&self, workspace_id: &Uuid) -> Result<AFWorkspace, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/open\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFWorkspace>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_favorite(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<FavoriteSectionItems, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/favorite\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<FavoriteSectionItems>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_recent(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<RecentSectionItems, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/recent\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<RecentSectionItems>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_trash(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<TrashSectionItems, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/trash\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<TrashSectionItems>(resp).await\n  }\n\n  pub async fn join_workspace_by_invitation_code(\n    &self,\n    invitation_code: &str,\n  ) -> Result<InvitedWorkspace, AppResponseError> {\n    let url = format!(\"{}/api/workspace/join-by-invite-code\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&JoinWorkspaceByInviteCodeParams {\n        code: invitation_code.to_string(),\n      })\n      .send()\n      .await?;\n    process_response_data::<InvitedWorkspace>(resp).await\n  }\n\n  pub async fn get_invitation_code_info(\n    &self,\n    invitation_code: &str,\n  ) -> Result<InvitationCodeInfo, AppResponseError> {\n    let url = format!(\"{}/api/invite-code-info\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&GetInvitationCodeInfoQuery {\n        code: invitation_code.to_string(),\n      })\n      .send()\n      .await?;\n    process_response_data::<InvitationCodeInfo>(resp).await\n  }\n\n  pub async fn create_workspace_invitation_code(\n    &self,\n    workspace_id: &Uuid,\n    params: &WorkspaceInviteCodeParams,\n  ) -> Result<WorkspaceInviteCode, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/invite-code\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_data::<WorkspaceInviteCode>(resp).await\n  }\n\n  pub async fn get_workspace_invitation_code(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<WorkspaceInviteCode, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/invite-code\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<WorkspaceInviteCode>(resp).await\n  }\n\n  pub async fn delete_workspace_invitation_code(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/invite-code\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn sign_up(&self, email: &str, password: &str) -> Result<(), AppResponseError> {\n    match self.gotrue_client.sign_up(email, password, None).await? {\n      Authenticated(access_token_resp) => {\n        self.token.write().set(access_token_resp.clone());\n        Ok(())\n      },\n      NotAuthenticated(user) => {\n        tracing::info!(\"sign_up but not authenticated: {}\", user.email);\n        Ok(())\n      },\n    }\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn sign_out(&self) -> Result<(), AppResponseError> {\n    self.gotrue_client.logout(&self.access_token()?).await?;\n    self.token.write().unset();\n    Ok(())\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn update_user(&self, params: UpdateUserParams) -> Result<(), AppResponseError> {\n    let gotrue_params = UpdateGotrueUserParams::new()\n      .with_opt_email(params.email.clone())\n      .with_opt_password(params.password.clone());\n\n    let updated_user = self\n      .gotrue_client\n      .update_user(&self.access_token()?, &gotrue_params)\n      .await?;\n\n    if let Some(token) = self.token.write().as_mut() {\n      token.user = updated_user;\n    }\n\n    let url = format!(\"{}/api/user/update\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn delete_user(&self) -> Result<(), AppResponseError> {\n    let (provider_access_token, provider_refresh_token) = {\n      let token = &self.token;\n      let token_read = token.read();\n      let token_resp = token_read\n        .as_ref()\n        .ok_or(AppResponseError::from(AppError::NotLoggedIn(\n          \"token is empty\".to_string(),\n        )))?;\n      (\n        token_resp.provider_access_token.clone(),\n        token_resp.provider_refresh_token.clone(),\n      )\n    };\n\n    let url = format!(\"{}/api/user\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .query(&DeleteUserQuery {\n        provider_access_token,\n        provider_refresh_token,\n      })\n      .send()\n      .await?;\n\n    process_response_error(resp).await\n  }\n\n  pub async fn ws_connect_info(&self, auto_refresh: bool) -> Result<ConnectInfo, AppResponseError> {\n    if auto_refresh {\n      self\n        .refresh_if_expired(\n          chrono::Local::now().timestamp(),\n          \"get websocket connect info\",\n        )\n        .await?;\n    }\n\n    Ok(ConnectInfo {\n      access_token: self.access_token()?,\n      client_version: self.client_version.clone(),\n      device_id: self.device_id.clone(),\n    })\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_workspace_usage(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<WorkspaceSpaceUsage, AppResponseError> {\n    let url = format!(\"{}/api/file_storage/{}/usage\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<WorkspaceSpaceUsage>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_server_info(&self) -> Result<ServerInfoResponseItem, AppResponseError> {\n    let url = format!(\"{}/api/server\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    if resp.status() == StatusCode::NOT_FOUND {\n      Err(AppResponseError::new(\n        ErrorCode::Unhandled,\n        \"server info not implemented\",\n      ))\n    } else {\n      process_response_data::<ServerInfoResponseItem>(resp).await\n    }\n  }\n\n  /// Refreshes the access token using the stored refresh token.\n  ///\n  /// attempts to refresh the access token by sending a request to the authentication server\n  /// using the stored refresh token. If successful, it updates the stored access token with the new one\n  /// received from the server.\n  /// Refreshes the access token using the stored refresh token.\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn refresh_token(&self, reason: &str) -> Result<(), AppResponseError> {\n    let (tx, rx) = tokio::sync::oneshot::channel();\n    self.refresh_ret_txs.write().push(tx);\n\n    // Atomically check and set the refreshing flag to prevent race conditions\n    if self\n      .is_refreshing_token\n      .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)\n      .is_ok()\n    {\n      info!(\"refresh token reason:{}\", reason);\n      let result = self.inner_refresh_token().await;\n\n      // Process all pending requests and reset state atomically\n      let mut txs_guard = self.refresh_ret_txs.write();\n      let txs = std::mem::take(&mut *txs_guard);\n      self.is_refreshing_token.store(false, Ordering::SeqCst);\n      drop(txs_guard);\n\n      // Send results to all waiting requests\n      for tx in txs {\n        let _ = tx.send(result.clone());\n      }\n    } else {\n      debug!(\"refresh token is already in progress\");\n    }\n\n    // Wait for the result of the refresh token request.\n    match tokio::time::timeout(Duration::from_secs(60), rx).await {\n      Ok(Ok(result)) => result,\n      Ok(Err(err)) => {\n        Err(AppError::Internal(anyhow!(\"refresh token channel error: {}\", err)).into())\n      },\n      Err(_) => Err(AppError::RequestTimeout(\"refresh token timeout\".to_string()).into()),\n    }\n  }\n\n  async fn inner_refresh_token(&self) -> Result<(), AppResponseError> {\n    let retry_strategy = FixedInterval::new(Duration::from_secs(2)).take(4);\n    let action = RefreshTokenAction::new(self.token.clone(), self.gotrue_client.clone());\n    match RetryIf::spawn(retry_strategy, action, RefreshTokenRetryCondition).await {\n      Ok(_) => {\n        event!(tracing::Level::INFO, \"refresh token success\");\n        Ok(())\n      },\n      Err(err) => {\n        let err = AppError::from(err);\n        event!(tracing::Level::ERROR, \"refresh token failed: {}\", err);\n        // If the error is an OAuth error, unset the token.\n        if err.is_unauthorized() {\n          self.token.write().unset();\n        }\n        Err(err.into())\n      },\n    }\n  }\n\n  // Refresh token if given timestamp is close to the token expiration time\n  pub async fn refresh_if_expired(&self, ts: i64, reason: &str) -> Result<(), AppResponseError> {\n    let expires_at = self.token_expires_at()?;\n\n    if ts + 30 > expires_at {\n      info!(\"token is about to expire, refreshing token\");\n      // Add 30 seconds buffer\n      self.refresh_token(reason).await?;\n    }\n    Ok(())\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn http_client_without_auth(\n    &self,\n    method: Method,\n    url: &str,\n  ) -> Result<RequestBuilder, AppResponseError> {\n    trace!(\"start request: {}, method: {}\", url, method,);\n    Ok(self.cloud_client.request(method, url))\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn http_client_with_auth(\n    &self,\n    method: Method,\n    url: &str,\n  ) -> Result<RequestBuilder, AppResponseError> {\n    let ts_now = chrono::Local::now().timestamp();\n    self\n      .refresh_if_expired(ts_now, \"make http client request\")\n      .await?;\n\n    let access_token = self.access_token()?;\n    let headers = [\n      (\"client-version\", self.client_version.to_string()),\n      (\"client-timestamp\", ts_now.to_string()),\n      (\"device-id\", self.device_id.clone()),\n    ];\n    trace!(\n      \"start request: {}, method: {}, headers: {:?}\",\n      url,\n      method,\n      headers\n    );\n\n    let mut request_builder = self\n      .cloud_client\n      .request(method, url)\n      .bearer_auth(access_token);\n\n    for header in headers {\n      request_builder = request_builder.header(header.0, header.1);\n    }\n    Ok(request_builder)\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn http_client_with_model(\n    &self,\n    method: Method,\n    url: &str,\n    ai_model: Option<String>,\n  ) -> Result<RequestBuilder, AppResponseError> {\n    let mut builder = self.http_client_with_auth(method, url).await?;\n    let effective_ai_model = match ai_model {\n      Some(model) => model,\n      None => self.ai_model.read().clone(),\n    };\n\n    builder = builder.header(\"ai-model\", effective_ai_model);\n    Ok(builder)\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub(crate) async fn http_client_with_auth_compress(\n    &self,\n    method: Method,\n    url: &str,\n  ) -> Result<RequestBuilder, AppResponseError> {\n    #[cfg(feature = \"enable_brotli\")]\n    {\n      self\n        .http_client_with_auth(method, url)\n        .await\n        .map(|builder| {\n          builder\n            .header(\n              crate::http::X_COMPRESSION_TYPE,\n              reqwest::header::HeaderValue::from_static(crate::http::X_COMPRESSION_TYPE_BROTLI),\n            )\n            .header(\n              crate::http::X_COMPRESSION_BUFFER_SIZE,\n              reqwest::header::HeaderValue::from(self.config.compression_buffer_size),\n            )\n        })\n    }\n\n    #[cfg(not(feature = \"enable_brotli\"))]\n    self.http_client_with_auth(method, url).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub(crate) fn batch_create_collab_url(&self, workspace_id: &Uuid) -> String {\n    format!(\n      \"{}/api/workspace/{}/batch/collab\",\n      self.base_url, workspace_id\n    )\n  }\n}\n\nimpl Display for Client {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"Client {{ base_url: {}, ws_addr: {}, gotrue_url: {} }}\",\n      self.base_url, self.ws_addr, self.gotrue_client.base_url\n    ))\n  }\n}\n\nfn url_missing_param(param: &str) -> AppResponseError {\n  AppError::InvalidRequest(format!(\"Url Missing Parameter:{}\", param)).into()\n}\n\n#[cfg(feature = \"enable_brotli\")]\npub fn brotli_compress(\n  data: Vec<u8>,\n  quality: u32,\n  buffer_size: usize,\n) -> Result<Vec<u8>, AppError> {\n  let mut compressor = brotli::CompressorReader::new(&*data, buffer_size, quality, 22);\n  let mut compressed_data = Vec::new();\n  compressor\n    .read_to_end(&mut compressed_data)\n    .map_err(|err| AppError::InvalidRequest(format!(\"Failed to compress data: {}\", err)))?;\n  Ok(compressed_data)\n}\n\n#[cfg(feature = \"enable_brotli\")]\npub async fn blocking_brotli_compress(\n  data: Vec<u8>,\n  quality: u32,\n  buffer_size: usize,\n) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || brotli_compress(data, quality, buffer_size))\n    .await\n    .map_err(AppError::from)?\n}\n\n#[cfg(not(feature = \"enable_brotli\"))]\npub async fn blocking_brotli_compress(\n  data: Vec<u8>,\n  _quality: u32,\n  _buffer_size: usize,\n) -> Result<Vec<u8>, AppError> {\n  Ok(data)\n}\n\n#[cfg(not(feature = \"enable_brotli\"))]\npub fn brotli_compress(\n  data: Vec<u8>,\n  _quality: u32,\n  _buffer_size: usize,\n) -> Result<Vec<u8>, AppError> {\n  Ok(data)\n}\nfn attach_request_id(\n  mut err: AppResponseError,\n  request_id: impl std::fmt::Debug,\n) -> AppResponseError {\n  err.message = Cow::Owned(format!(\"{}. request_id: {:?}\", err.message, request_id));\n  err\n}\n\npub async fn process_response_data<T>(resp: reqwest::Response) -> Result<T, AppResponseError>\nwhere\n  T: serde::de::DeserializeOwned + 'static,\n{\n  let request_id = extract_request_id(&resp);\n\n  AppResponse::<T>::from_response(resp)\n    .await\n    .map_err(|err| {\n      error!(\n        \"Error parsing response, request_id: {:?}, error: {}\",\n        request_id, err\n      );\n      AppResponseError::from(err)\n    })\n    .and_then(|app_response| {\n      app_response\n        .into_data()\n        .map_err(|err| attach_request_id(err, &request_id))\n    })\n}\n\npub async fn process_response_error(resp: reqwest::Response) -> Result<(), AppResponseError> {\n  let request_id = extract_request_id(&resp);\n\n  AppResponse::<()>::from_response(resp)\n    .await?\n    .into_error()\n    .map_err(|err| attach_request_id(err, &request_id))\n}\nfn extract_request_id(resp: &reqwest::Response) -> Option<String> {\n  resp\n    .headers()\n    .get(\"x-request-id\")\n    .map(|v| v.to_str().unwrap_or(\"invalid\").to_string())\n}\n"
  },
  {
    "path": "libs/client-api/src/http_access_request.rs",
    "content": "use client_api_entity::{\n  access_request_dto::AccessRequest, AccessRequestMinimal, ApproveAccessRequestParams,\n  CreateAccessRequestParams,\n};\nuse reqwest::Method;\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nimpl Client {\n  pub async fn get_access_request(\n    &self,\n    access_request_id: Uuid,\n  ) -> Result<AccessRequest, AppResponseError> {\n    let url = format!(\"{}/api/access-request/{}\", self.base_url, access_request_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AccessRequest>(resp).await\n  }\n\n  pub async fn create_access_request(\n    &self,\n    data: CreateAccessRequestParams,\n  ) -> Result<AccessRequestMinimal, AppResponseError> {\n    let url = format!(\"{}/api/access-request\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&data)\n      .send()\n      .await?;\n    process_response_data::<AccessRequestMinimal>(resp).await\n  }\n\n  pub async fn approve_access_request(\n    &self,\n    access_request_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/access-request/{}/approve\",\n      self.base_url, access_request_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&ApproveAccessRequestParams { is_approved: true })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn reject_access_request(\n    &self,\n    access_request_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/access-request/{}/approve\",\n      self.base_url, access_request_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&ApproveAccessRequestParams { is_approved: false })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_ai.rs",
    "content": "use crate::http_chat::CompletionStream;\nuse crate::{process_response_data, Client};\nuse bytes::Bytes;\nuse futures_core::Stream;\nuse reqwest::Method;\nuse shared_entity::dto::ai_dto::{\n  CompleteTextParams, LocalAIConfig, ModelList, SummarizeRowParams, SummarizeRowResponse,\n  TranslateRowParams, TranslateRowResponse,\n};\nuse shared_entity::response::{AppResponse, AppResponseError};\nuse std::time::Duration;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nimpl Client {\n  pub async fn stream_completion_text(\n    &self,\n    workspace_id: &str,\n    params: CompleteTextParams,\n  ) -> Result<impl Stream<Item = Result<Bytes, AppResponseError>>, AppResponseError> {\n    let url = format!(\"{}/api/ai/{}/complete/stream\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_model(Method::POST, &url, None)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    AppResponse::<()>::answer_response_stream(resp).await\n  }\n\n  pub async fn stream_completion_v2(\n    &self,\n    workspace_id: &Uuid,\n    params: CompleteTextParams,\n    ai_model: Option<String>,\n  ) -> Result<CompletionStream, AppResponseError> {\n    let url = format!(\n      \"{}/api/ai/{}/v2/complete/stream\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_model(Method::POST, &url, ai_model)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    let stream = AppResponse::<serde_json::Value>::json_response_stream(resp).await?;\n    Ok(CompletionStream::new(stream))\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn summarize_row(\n    &self,\n    params: SummarizeRowParams,\n  ) -> Result<SummarizeRowResponse, AppResponseError> {\n    let url = format!(\n      \"{}/api/ai/{}/summarize_row\",\n      self.base_url, params.workspace_id\n    );\n\n    let resp = self\n      .http_client_with_model(Method::POST, &url, None)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n\n    process_response_data::<SummarizeRowResponse>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn translate_row(\n    &self,\n    params: TranslateRowParams,\n  ) -> Result<TranslateRowResponse, AppResponseError> {\n    let url = format!(\n      \"{}/api/ai/{}/translate_row\",\n      self.base_url, params.workspace_id\n    );\n\n    let resp = self\n      .http_client_with_model(Method::POST, &url, None)\n      .await?\n      .json(&params)\n      .timeout(Duration::from_secs(30))\n      .send()\n      .await?;\n\n    process_response_data::<TranslateRowResponse>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_local_ai_config(\n    &self,\n    workspace_id: &str,\n    platform: &str,\n  ) -> Result<LocalAIConfig, AppResponseError> {\n    let client_version = self.client_version.to_string();\n    let url = format!(\n      \"{}/api/ai/{}/local/config?platform={platform}&app_version={client_version}\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<LocalAIConfig>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn get_model_list(&self, workspace_id: &Uuid) -> Result<ModelList, AppResponseError> {\n    let url = format!(\"{}/api/ai/{workspace_id}/model/list\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<ModelList>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_billing.rs",
    "content": "use crate::{process_response_data, process_response_error, Client};\nuse client_api_entity::billing_dto::{\n  SetSubscriptionRecurringInterval, SubscriptionCancelRequest, SubscriptionLinkRequest,\n  SubscriptionPlanDetail, WorkspaceUsageAndLimit,\n};\nuse reqwest::Method;\nuse shared_entity::{\n  dto::billing_dto::{RecurringInterval, SubscriptionPlan, WorkspaceSubscriptionStatus},\n  response::{AppResponse, AppResponseError},\n};\n\nlazy_static::lazy_static! {\n  static ref BASE_BILLING_URL: Option<String> = match std::env::var(\"APPFLOWY_CLOUD_BASE_BILLING_URL\") {\n    Ok(url) => Some(url),\n    Err(err) => {\n      tracing::warn!(\"std::env::var(APPFLOWY_CLOUD_BASE_BILLING_URL): {}\", err);\n      None\n    },\n  };\n}\n\nimpl Client {\n  pub fn base_billing_url(&self) -> &str {\n    BASE_BILLING_URL.as_deref().unwrap_or(&self.base_url)\n  }\n\n  pub async fn customer_id(&self) -> Result<String, AppResponseError> {\n    let url = format!(\"{}/billing/api/v1/customer-id\", self.base_billing_url());\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<String>(resp).await\n  }\n\n  pub async fn create_subscription(\n    &self,\n    workspace_id: &str,\n    recurring_interval: RecurringInterval,\n    workspace_subscription_plan: SubscriptionPlan,\n    success_url: &str,\n  ) -> Result<String, AppResponseError> {\n    let sub_link_req = SubscriptionLinkRequest {\n      workspace_subscription_plan,\n      recurring_interval,\n      workspace_id: workspace_id.to_string(),\n      success_url: success_url.to_string(),\n      with_test_clock: None,\n    };\n\n    self.create_subscription_v2(&sub_link_req).await\n  }\n\n  pub async fn create_subscription_v2(\n    &self,\n    sub_link_req: &SubscriptionLinkRequest,\n  ) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/subscription-link\",\n      self.base_billing_url()\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(sub_link_req)\n      .send()\n      .await?;\n\n    process_response_data::<String>(resp).await\n  }\n\n  pub async fn cancel_subscription(\n    &self,\n    req: &SubscriptionCancelRequest,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/cancel-subscription\",\n      self.base_billing_url()\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(req)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn list_subscription(\n    &self,\n  ) -> Result<Vec<WorkspaceSubscriptionStatus>, AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/subscription-status\",\n      self.base_billing_url(),\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<Vec<WorkspaceSubscriptionStatus>>(resp).await\n  }\n\n  pub async fn get_portal_session_link(&self) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/portal-session-link\",\n      self.base_billing_url()\n    );\n    let portal_url = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?\n      .error_for_status()?\n      .json::<AppResponse<String>>()\n      .await?\n      .into_data()?;\n    Ok(portal_url)\n  }\n\n  pub async fn get_workspace_usage_and_limit(\n    &self,\n    workspace_id: &str,\n  ) -> Result<WorkspaceUsageAndLimit, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/usage-and-limit\",\n      self.base_url, workspace_id\n    );\n    self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?\n      .error_for_status()?\n      .json::<AppResponse<WorkspaceUsageAndLimit>>()\n      .await?\n      .into_data()\n  }\n\n  /// Query all subscription status for a workspace\n  pub async fn get_workspace_subscriptions(\n    &self,\n    workspace_id: &str,\n  ) -> Result<Vec<WorkspaceSubscriptionStatus>, AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/subscription-status/{}\",\n      self.base_billing_url(),\n      workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<Vec<WorkspaceSubscriptionStatus>>(resp).await\n  }\n\n  /// Query all active subscription, minimal information but faster\n  pub async fn get_active_workspace_subscriptions(\n    &self,\n    workspace_id: &str,\n  ) -> Result<Vec<SubscriptionPlan>, AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/active-subscription/{}\",\n      self.base_billing_url(),\n      workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<Vec<SubscriptionPlan>>(resp).await\n  }\n\n  /// Set subscription recurring interval\n  pub async fn set_subscription_recurring_interval(\n    &self,\n    set_sub_recur: &SetSubscriptionRecurringInterval,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/billing/api/v1/subscription-recurring-interval\",\n      self.base_billing_url(),\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(set_sub_recur)\n      .send()\n      .await?;\n\n    process_response_error(resp).await\n  }\n\n  /// get all subscription plan details\n  pub async fn get_subscription_plan_details(\n    &self,\n  ) -> Result<Vec<SubscriptionPlanDetail>, AppResponseError> {\n    let url = format!(\"{}/billing/api/v1/subscriptions\", self.base_billing_url(),);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<Vec<SubscriptionPlanDetail>>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_blob.rs",
    "content": "use crate::{process_response_data, process_response_error, Client};\n\nuse app_error::AppError;\nuse bytes::Bytes;\nuse futures_util::TryStreamExt;\nuse mime::Mime;\nuse percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};\nuse reqwest::{header, Method, StatusCode};\nuse shared_entity::dto::workspace_dto::{BlobMetadata, RepeatedBlobMetaData};\nuse shared_entity::response::AppResponseError;\n\nuse shared_entity::dto::file_dto::PutFileResponse;\nuse tracing::instrument;\nuse url::Url;\nuse uuid::Uuid;\n\nimpl Client {\n  pub fn get_blob_url(&self, workspace_id: &Uuid, file_id: &str) -> String {\n    format!(\n      \"{}/api/file_storage/{}/blob/{}\",\n      self.base_url, workspace_id, file_id\n    )\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn put_blob<T: Into<Bytes>>(\n    &self,\n    url: &str,\n    data: T,\n    mime: &Mime,\n  ) -> Result<(), AppResponseError> {\n    let data = data.into();\n    let resp = self\n      .http_client_with_auth(Method::PUT, url)\n      .await?\n      .header(header::CONTENT_TYPE, mime.to_string())\n      .body(data)\n      .send()\n      .await?;\n    if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {\n      return Err(AppResponseError::from(AppError::PayloadTooLarge(\n        StatusCode::PAYLOAD_TOO_LARGE.to_string(),\n      )));\n    }\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn put_blob_v1<T: Into<Bytes>>(\n    &self,\n    workspace_id: &Uuid,\n    parent_dir: &str,\n    data: T,\n    mime: &Mime,\n  ) -> Result<PutFileResponse, AppResponseError> {\n    let url = format!(\n      \"{}/api/file_storage/{}/v1/blob/{}\",\n      self.base_url, workspace_id, parent_dir\n    );\n    let data = data.into();\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .header(header::CONTENT_TYPE, mime.to_string())\n      .body(data)\n      .send()\n      .await?;\n\n    if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {\n      return Err(AppResponseError::from(AppError::PayloadTooLarge(\n        StatusCode::PAYLOAD_TOO_LARGE.to_string(),\n      )));\n    }\n    process_response_data::<PutFileResponse>(resp).await\n  }\n\n  /// Only expose this method for testing\n  #[instrument(level = \"info\", skip_all)]\n  #[cfg(debug_assertions)]\n  pub async fn put_blob_with_content_length<T: Into<Bytes>>(\n    &self,\n    url: &str,\n    data: T,\n    mime: &Mime,\n    content_length: usize,\n  ) -> Result<crate::entity::AFBlobRecord, AppResponseError> {\n    let resp = self\n      .http_client_with_auth(Method::PUT, url)\n      .await?\n      .header(header::CONTENT_TYPE, mime.to_string())\n      .header(header::CONTENT_LENGTH, content_length)\n      .body(data.into())\n      .send()\n      .await?;\n    process_response_data::<crate::entity::AFBlobRecord>(resp).await\n  }\n  pub fn get_blob_url_v1(&self, workspace_id: &Uuid, parent_dir: &str, file_id: &str) -> String {\n    let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string();\n    format!(\n      \"{}/api/file_storage/{workspace_id}/v1/blob/{parent_dir}/{file_id}\",\n      self.base_url\n    )\n  }\n\n  /// Returns the workspace_id, parent_dir, and file_id from the given blob url.\n  pub fn parse_blob_url_v1(&self, url: &str) -> Option<(Uuid, String, String)> {\n    let parsed_url = Url::parse(url).ok()?;\n    let segments: Vec<&str> = parsed_url.path_segments()?.collect();\n    // Check if the path has the expected number of segments\n    if segments.len() < 6 {\n      return None;\n    }\n\n    // Extract the workspace_id, parent_dir, and file_id from the segments\n    let workspace_id: Uuid = segments[2].parse().ok()?;\n    let encoded_parent_dir = segments[5].to_string();\n    let file_id = segments[6].to_string();\n\n    // Decode the percent-encoded parent_dir\n    let parent_dir = percent_decode_str(&encoded_parent_dir)\n      .decode_utf8()\n      .ok()?\n      .to_string();\n\n    Some((workspace_id, parent_dir, file_id))\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_blob_v1(\n    &self,\n    workspace_id: &Uuid,\n    parent_dir: &str,\n    file_id: &str,\n  ) -> Result<(Mime, Vec<u8>), AppResponseError> {\n    // Encode the parent directory to ensure it's URL-safe.\n    let url = self.get_blob_url_v1(workspace_id, parent_dir, file_id);\n    self.get_blob(&url).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn delete_blob_v1(\n    &self,\n    workspace_id: &str,\n    parent_dir: &str,\n    file_id: &str,\n  ) -> Result<(), AppResponseError> {\n    let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string();\n    let url = format!(\n      \"{}/api/file_storage/{workspace_id}/v1/blob/{parent_dir}/{file_id}\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_blob_v1_metadata(\n    &self,\n    workspace_id: &str,\n    parent_dir: &str,\n    file_id: &str,\n  ) -> Result<BlobMetadata, AppResponseError> {\n    let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string();\n    let url = format!(\n      \"{}/api/file_storage/{workspace_id}/v1/metadata/{parent_dir}/{file_id}\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<BlobMetadata>(resp).await\n  }\n\n  /// Get the file with the given url. The url should be in the format of\n  /// `https://appflowy.io/api/file_storage/<workspace_id>/<file_id>`.\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_blob(&self, url: &str) -> Result<(Mime, Vec<u8>), AppResponseError> {\n    let resp = self\n      .http_client_with_auth(Method::GET, url)\n      .await?\n      .send()\n      .await?;\n\n    match resp.status() {\n      StatusCode::OK => {\n        let mime = resp\n          .headers()\n          .get(header::CONTENT_TYPE)\n          .and_then(|v| v.to_str().ok())\n          .and_then(|v| v.parse::<Mime>().ok())\n          .unwrap_or(mime::TEXT_PLAIN);\n\n        let bytes = resp\n          .bytes_stream()\n          .try_fold(Vec::new(), |mut acc, chunk| async move {\n            acc.extend_from_slice(&chunk);\n            Ok(acc)\n          })\n          .await?;\n\n        Ok((mime, bytes))\n      },\n      StatusCode::NOT_FOUND => Err(AppResponseError::from(AppError::RecordNotFound(\n        url.to_owned(),\n      ))),\n      status => {\n        let message = resp\n          .text()\n          .await\n          .unwrap_or_else(|_| \"Unknown error\".to_string());\n        Err(AppResponseError::from(AppError::Unhandled(format!(\n          \"status code: {}, message: {}\",\n          status, message\n        ))))\n      },\n    }\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn get_blob_metadata(&self, url: &str) -> Result<BlobMetadata, AppResponseError> {\n    let resp = self\n      .http_client_with_auth(Method::GET, url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<BlobMetadata>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all)]\n  pub async fn delete_blob(&self, url: &str) -> Result<(), AppResponseError> {\n    let resp = self\n      .http_client_with_auth(Method::DELETE, url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_workspace_all_blob_metadata(\n    &self,\n    workspace_id: &str,\n  ) -> Result<RepeatedBlobMetaData, AppResponseError> {\n    let url = format!(\"{}/api/file_storage/{}/blobs\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<RepeatedBlobMetaData>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_chat.rs",
    "content": "use crate::{process_response_data, process_response_error, Client};\n\nuse app_error::AppError;\nuse client_api_entity::chat_dto::{\n  ChatMessage, CreateAnswerMessageParams, CreateChatMessageParams, CreateChatParams, MessageCursor,\n  RepeatedChatMessage, RepeatedChatMessageWithAuthorUuid, UpdateChatMessageContentParams,\n};\nuse futures_core::{ready, Stream};\nuse pin_project::pin_project;\nuse reqwest::Method;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse shared_entity::dto::ai_dto::{\n  CalculateSimilarityParams, ChatQuestionQuery, RepeatedRelatedQuestion, SimilarityResponse,\n  STREAM_ANSWER_KEY, STREAM_COMMENT_KEY, STREAM_IMAGE_KEY, STREAM_METADATA_KEY,\n};\nuse shared_entity::dto::chat_dto::{ChatSettings, UpdateChatParams};\nuse shared_entity::response::{AppResponse, AppResponseError};\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse std::time::Duration;\nuse tracing::error;\nuse uuid::Uuid;\n\nimpl Client {\n  /// Create a new chat\n  pub async fn create_chat(\n    &self,\n    workspace_id: &Uuid,\n    params: CreateChatParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/chat/{workspace_id}\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_chat_settings(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    params: UpdateChatParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/settings\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n  pub async fn get_chat_settings(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n  ) -> Result<ChatSettings, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/settings\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<ChatSettings>(resp).await\n  }\n\n  /// Delete a chat for given chat_id\n  pub async fn delete_chat(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/chat/{workspace_id}/{chat_id}\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  /// Save a question message to a chat\n  pub async fn create_question(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    params: CreateChatMessageParams,\n  ) -> Result<ChatMessage, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message/question\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<ChatMessage>(resp).await\n  }\n\n  /// save an answer message to a chat\n  pub async fn save_answer(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    params: CreateAnswerMessageParams,\n  ) -> Result<ChatMessage, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message/answer\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<ChatMessage>(resp).await\n  }\n\n  pub async fn stream_answer_v2(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    question_id: i64,\n  ) -> Result<QuestionStream, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/{question_id}/v2/answer/stream\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .timeout(Duration::from_secs(30))\n      .send()\n      .await\n      .map_err(|err| {\n        let app_err = AppError::from(err);\n        if matches!(app_err, AppError::ServiceTemporaryUnavailable(_)) {\n          AppError::AIServiceUnavailable(\n            \"AI service temporarily unavailable, please try again later\".to_string(),\n          )\n        } else {\n          app_err\n        }\n      })?;\n    let stream = AppResponse::<serde_json::Value>::json_response_stream(resp).await?;\n    Ok(QuestionStream::new(stream))\n  }\n\n  pub async fn stream_answer_v3(\n    &self,\n    workspace_id: &Uuid,\n    query: ChatQuestionQuery,\n    chat_model: Option<String>,\n  ) -> Result<QuestionStream, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{}/answer/stream\",\n      self.base_url, query.chat_id\n    );\n    let resp = self\n      .http_client_with_model(Method::POST, &url, chat_model)\n      .await?\n      .timeout(Duration::from_secs(60))\n      .json(&query)\n      .send()\n      .await?;\n    let stream = AppResponse::<serde_json::Value>::json_response_stream(resp).await?;\n    Ok(QuestionStream::new(stream))\n  }\n\n  pub async fn get_answer(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    question_message_id: i64,\n  ) -> Result<ChatMessage, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/{question_message_id}/answer\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<ChatMessage>(resp).await\n  }\n\n  /// Update chat message content. It will override the content of the message.\n  /// A message can be a question or an answer\n  pub async fn update_chat_message(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    params: UpdateChatMessageContentParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  /// Get related question for a chat message. The message_d should be the question's id\n  pub async fn get_chat_related_question(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    message_id: i64,\n  ) -> Result<RepeatedRelatedQuestion, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/{message_id}/related_question\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<RepeatedRelatedQuestion>(resp).await\n  }\n\n  /// Deprecated since v0.9.24. Return list of chat messages for a chat\n  pub async fn get_chat_messages(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    offset: MessageCursor,\n    limit: u64,\n  ) -> Result<RepeatedChatMessage, AppResponseError> {\n    let mut url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message\",\n      self.base_url\n    );\n    let mut query_params = vec![(\"limit\", limit.to_string())];\n    match offset {\n      MessageCursor::Offset(offset_value) => {\n        query_params.push((\"offset\", offset_value.to_string()));\n      },\n      MessageCursor::AfterMessageId(message_id) => {\n        query_params.push((\"after\", message_id.to_string()));\n      },\n      MessageCursor::BeforeMessageId(message_id) => {\n        query_params.push((\"before\", message_id.to_string()));\n      },\n      MessageCursor::NextBack => {},\n    }\n    let query = serde_urlencoded::to_string(&query_params).unwrap();\n    url = format!(\"{}?{}\", url, query);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<RepeatedChatMessage>(resp).await\n  }\n\n  /// Return list of chat messages for a chat. Each message will have author_uuid as\n  /// as the author's uid, as author_uid will face precision issue in the browser environment.\n  pub async fn get_chat_messages_with_author_uuid(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    offset: MessageCursor,\n    limit: u64,\n  ) -> Result<RepeatedChatMessageWithAuthorUuid, AppResponseError> {\n    let mut url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message\",\n      self.base_url\n    );\n    let mut query_params = vec![(\"limit\", limit.to_string())];\n    match offset {\n      MessageCursor::Offset(offset_value) => {\n        query_params.push((\"offset\", offset_value.to_string()));\n      },\n      MessageCursor::AfterMessageId(message_id) => {\n        query_params.push((\"after\", message_id.to_string()));\n      },\n      MessageCursor::BeforeMessageId(message_id) => {\n        query_params.push((\"before\", message_id.to_string()));\n      },\n      MessageCursor::NextBack => {},\n    }\n    let query = serde_urlencoded::to_string(&query_params).unwrap();\n    url = format!(\"{}?{}\", url, query);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<RepeatedChatMessageWithAuthorUuid>(resp).await\n  }\n\n  pub async fn get_question_message_from_answer_id(\n    &self,\n    workspace_id: &Uuid,\n    chat_id: &str,\n    answer_message_id: i64,\n  ) -> Result<Option<ChatMessage>, AppResponseError> {\n    let url = format!(\n      \"{}/api/chat/{workspace_id}/{chat_id}/message/find_question\",\n      self.base_url\n    );\n\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&[(\"answer_message_id\", answer_message_id)])\n      .send()\n      .await?;\n    process_response_data::<Option<ChatMessage>>(resp).await\n  }\n\n  pub async fn calculate_similarity(\n    &self,\n    params: CalculateSimilarityParams,\n  ) -> Result<SimilarityResponse, AppResponseError> {\n    let url = format!(\n      \"{}/api/ai/{}/calculate_similarity\",\n      self.base_url, &params.workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<SimilarityResponse>(resp).await\n  }\n}\n\n#[pin_project]\npub struct QuestionStream {\n  stream: Pin<Box<dyn Stream<Item = Result<serde_json::Value, AppResponseError>> + Send>>,\n  buffer: Vec<u8>,\n}\n\nimpl QuestionStream {\n  pub fn new<S>(stream: S) -> Self\n  where\n    S: Stream<Item = Result<serde_json::Value, AppResponseError>> + Send + 'static,\n  {\n    QuestionStream {\n      stream: Box::pin(stream),\n      buffer: Vec::new(),\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum QuestionStreamValue {\n  Answer {\n    value: String,\n  },\n  /// Metadata is a JSON array object. its structure as below:\n  /// ```json\n  /// [\n  ///   {\"id\": \"xx\", \"source\": \"\", \"name\": \"\" }\n  /// ]\n  Metadata {\n    value: Value,\n  },\n  SuggestedQuestion {\n    context_suggested_questions: Vec<ContextSuggestedQuestion>,\n  },\n  FollowUp {\n    should_generate_related_question: bool,\n  },\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ContextSuggestedQuestion {\n  pub content: String,\n  pub object_id: String,\n}\n\nimpl Stream for QuestionStream {\n  type Item = Result<QuestionStreamValue, AppResponseError>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let this = self.project();\n\n    match ready!(this.stream.as_mut().poll_next(cx)) {\n      Some(Ok(value)) => match value {\n        Value::Object(mut value) => {\n          if let Some(metadata) = value.remove(STREAM_METADATA_KEY) {\n            return Poll::Ready(Some(Ok(QuestionStreamValue::Metadata { value: metadata })));\n          }\n\n          if let Some(answer) = value\n            .remove(STREAM_ANSWER_KEY)\n            .and_then(|s| s.as_str().map(ToString::to_string))\n          {\n            return Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: answer })));\n          }\n\n          if let Some(image) = value\n            .remove(STREAM_IMAGE_KEY)\n            .and_then(|s| s.as_str().map(ToString::to_string))\n          {\n            return Poll::Ready(Some(Ok(QuestionStreamValue::Answer { value: image })));\n          }\n\n          error!(\"Invalid streaming value: {:?}\", value);\n          Poll::Ready(None)\n        },\n        _ => {\n          error!(\"Unexpected JSON value type: {:?}\", value);\n          Poll::Ready(None)\n        },\n      },\n      Some(Err(err)) => {\n        error!(\"Error while streaming answer: {:?}\", err);\n        Poll::Ready(Some(Err(err)))\n      },\n      None => Poll::Ready(None),\n    }\n  }\n}\n\n#[pin_project]\npub struct CompletionStream {\n  stream: Pin<Box<dyn Stream<Item = Result<serde_json::Value, AppResponseError>> + Send>>,\n  buffer: Vec<u8>,\n}\n\nimpl CompletionStream {\n  pub fn new<S>(stream: S) -> Self\n  where\n    S: Stream<Item = Result<serde_json::Value, AppResponseError>> + Send + 'static,\n  {\n    CompletionStream {\n      stream: Box::pin(stream),\n      buffer: Vec::new(),\n    }\n  }\n}\n\n#[derive(Debug, Clone)]\npub enum CompletionStreamValue {\n  Answer { value: String },\n  Comment { value: String },\n}\nimpl Stream for CompletionStream {\n  type Item = Result<CompletionStreamValue, AppResponseError>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let this = self.project();\n    match ready!(this.stream.as_mut().poll_next(cx)) {\n      Some(Ok(value)) => match value {\n        Value::Object(mut value) => {\n          if let Some(answer) = value\n            .remove(STREAM_ANSWER_KEY)\n            .and_then(|s| s.as_str().map(ToString::to_string))\n          {\n            return Poll::Ready(Some(Ok(CompletionStreamValue::Answer { value: answer })));\n          }\n\n          if let Some(comment) = value\n            .remove(STREAM_COMMENT_KEY)\n            .and_then(|s| s.as_str().map(ToString::to_string))\n          {\n            return Poll::Ready(Some(Ok(CompletionStreamValue::Comment { value: comment })));\n          }\n\n          error!(\"Invalid streaming value: {:?}\", value);\n          Poll::Ready(None)\n        },\n        _ => {\n          error!(\"Unexpected JSON value type: {:?}\", value);\n          Poll::Ready(None)\n        },\n      },\n      Some(Err(err)) => {\n        error!(\"Error while streaming answer: {:?}\", err);\n        Poll::Ready(Some(Err(err)))\n      },\n      None => Poll::Ready(None),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_collab.rs",
    "content": "use crate::entity::CollabType;\nuse crate::{\n  blocking_brotli_compress, brotli_compress, process_response_data, process_response_error, Client,\n};\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse client_api_entity::workspace_dto::{\n  AFDatabase, AFDatabaseField, AFDatabaseRow, AFDatabaseRowDetail, AFInsertDatabaseField,\n  AddDatatabaseRow, DatabaseRowUpdatedItem, ListDatabaseRowDetailParam,\n  ListDatabaseRowUpdatedParam, UpsertDatatabaseRow,\n};\nuse client_api_entity::{\n  AFCollabEmbedInfo, AFDatabaseRowDocumentCollabExistenceInfo, BatchQueryCollabParams,\n  BatchQueryCollabResult, CollabParams, CreateCollabData, CreateCollabParams, DeleteCollabParams,\n  PublishCollabItem, QueryCollab, QueryCollabParams, RepeatedAFCollabEmbedInfo,\n  UpdateCollabWebParams,\n};\nuse collab_rt_entity::collab_proto::{CollabDocStateParams, PayloadCompressionType};\nuse collab_rt_entity::HttpRealtimeMessage;\nuse futures::Stream;\nuse futures_util::stream;\nuse prost::Message;\nuse rayon::prelude::*;\nuse reqwest::{Body, Method};\nuse serde::Serialize;\nuse shared_entity::dto::workspace_dto::{CollabResponse, CollabTypeParam, EmbeddedCollabQuery};\nuse shared_entity::response::AppResponseError;\nuse std::collections::HashMap;\nuse std::future::Future;\nuse std::io::Cursor;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse std::time::Duration;\nuse tokio_retry::strategy::ExponentialBackoff;\nuse tokio_retry::{Action, Condition, RetryIf};\nuse tracing::{event, instrument};\nuse uuid::Uuid;\n\nimpl Client {\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn create_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/collab/{}\",\n      self.base_url, params.workspace_id, &params.object_id\n    );\n    let bytes = params\n      .to_bytes()\n      .map_err(|err| AppError::Internal(err.into()))?;\n\n    let compress_bytes = blocking_brotli_compress(\n      bytes,\n      self.config.compression_quality,\n      self.config.compression_buffer_size,\n    )\n    .await?;\n\n    #[allow(unused_mut)]\n    let mut builder = self\n      .http_client_with_auth_compress(Method::POST, &url)\n      .await?;\n\n    #[cfg(not(target_arch = \"wasm32\"))]\n    {\n      builder = builder.timeout(std::time::Duration::from_secs(60));\n    }\n\n    let resp = builder.body(compress_bytes).send().await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn update_collab(&self, params: CreateCollabParams) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/collab/{}\",\n      self.base_url, &params.workspace_id, &params.object_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_web_collab(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    params: UpdateCollabWebParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/v1/{}/collab/{}/web-update\",\n      self.base_url, workspace_id, object_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  // The browser will call this API to get the collab list, because the URL length limit and browser can't send the body in GET request\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn batch_post_collab(\n    &self,\n    workspace_id: &Uuid,\n    params: Vec<QueryCollab>,\n  ) -> Result<BatchQueryCollabResult, AppResponseError> {\n    self\n      .send_batch_collab_request(Method::POST, workspace_id, params)\n      .await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn batch_get_collab(\n    &self,\n    workspace_id: &Uuid,\n    params: Vec<QueryCollab>,\n  ) -> Result<BatchQueryCollabResult, AppResponseError> {\n    self\n      .send_batch_collab_request(Method::GET, workspace_id, params)\n      .await\n  }\n\n  async fn send_batch_collab_request(\n    &self,\n    method: Method,\n    workspace_id: &Uuid,\n    params: Vec<QueryCollab>,\n  ) -> Result<BatchQueryCollabResult, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/collab_list\",\n      self.base_url, workspace_id\n    );\n    let params = BatchQueryCollabParams(params);\n    let resp = self\n      .http_client_with_auth(method, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<BatchQueryCollabResult>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn delete_collab(&self, params: DeleteCollabParams) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/collab/{}\",\n      self.base_url, &params.workspace_id, &params.object_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn list_databases(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<AFDatabase>, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/database\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<AFDatabase>>(resp).await\n  }\n\n  pub async fn list_database_row_ids(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n  ) -> Result<Vec<AFDatabaseRow>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/row\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<AFDatabaseRow>>(resp).await\n  }\n\n  pub async fn get_database_fields(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n  ) -> Result<Vec<AFDatabaseField>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/fields\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<AFDatabaseField>>(resp).await\n  }\n\n  // Adds a database field to the specified database.\n  // Returns the field id of the newly created field.\n  pub async fn add_database_field(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n    insert_field: &AFInsertDatabaseField,\n  ) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/fields\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(insert_field)\n      .send()\n      .await?;\n    process_response_data::<String>(resp).await\n  }\n\n  pub async fn list_database_row_ids_updated(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n    after: Option<DateTime<Utc>>,\n  ) -> Result<Vec<DatabaseRowUpdatedItem>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/row/updated\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&ListDatabaseRowUpdatedParam { after })\n      .send()\n      .await?;\n    process_response_data::<Vec<DatabaseRowUpdatedItem>>(resp).await\n  }\n\n  pub async fn list_database_row_details(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n    row_ids: &[&str],\n    with_doc: bool,\n  ) -> Result<Vec<AFDatabaseRowDetail>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/row/detail\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&ListDatabaseRowDetailParam::new(row_ids, with_doc))\n      .send()\n      .await?;\n    process_response_data::<Vec<AFDatabaseRowDetail>>(resp).await\n  }\n\n  /// Example payload:\n  /// {\n  ///   \"Name\": \"some_data\",        # using column name\n  ///   \"_pIkG\": \"some other data\"  # using field_id (can be obtained from [get_database_fields])\n  /// }\n  /// Upon success, returns the row id for the newly created row.\n  pub async fn add_database_item(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n    cells_by_id: HashMap<String, serde_json::Value>,\n    row_doc_content: Option<String>,\n  ) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/row\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&AddDatatabaseRow {\n        cells: cells_by_id,\n        document: row_doc_content,\n      })\n      .send()\n      .await?;\n    process_response_data::<String>(resp).await\n  }\n\n  /// Like [add_database_item], but use a [pre_hash] as identifier of the row\n  /// Given the same `pre_hash` value will result in the same row\n  /// Creates the row if now exists, else row will be modified\n  pub async fn upsert_database_item(\n    &self,\n    workspace_id: &Uuid,\n    database_id: &str,\n    pre_hash: String,\n    cells_by_id: HashMap<String, serde_json::Value>,\n    row_doc_content: Option<String>,\n  ) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/database/{}/row\",\n      self.base_url, workspace_id, database_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&UpsertDatatabaseRow {\n        pre_hash,\n        cells: cells_by_id,\n        document: row_doc_content,\n      })\n      .send()\n      .await?;\n    process_response_data::<String>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn post_realtime_msg(\n    &self,\n    device_id: &str,\n    msg: client_websocket::Message,\n  ) -> Result<(), AppResponseError> {\n    let device_id = device_id.to_string();\n    let payload =\n      blocking_brotli_compress(msg.into_data(), 6, self.config.compression_buffer_size).await?;\n\n    let msg = HttpRealtimeMessage { device_id, payload }.encode_to_vec();\n    let body = Body::wrap_stream(stream::iter(vec![Ok::<_, reqwest::Error>(msg)]));\n    let url = format!(\"{}/api/realtime/post/stream\", self.base_url);\n    let resp = self\n      .http_client_with_auth_compress(Method::POST, &url)\n      .await?\n      .body(body)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub async fn create_collab_list(\n    &self,\n    workspace_id: &Uuid,\n    params_list: Vec<CollabParams>,\n  ) -> Result<(), AppResponseError> {\n    let url = self.batch_create_collab_url(workspace_id);\n\n    let compression_tasks = params_list\n      .into_par_iter()\n      .filter_map(|params| {\n        let data = CreateCollabData::from(params).to_bytes().ok()?;\n        brotli_compress(\n          data,\n          self.config.compression_quality,\n          self.config.compression_buffer_size,\n        )\n        .ok()\n      })\n      .collect::<Vec<_>>();\n\n    let mut framed_data = Vec::new();\n    let mut size_count = 0;\n    for compressed in compression_tasks {\n      // The length of a u32 in bytes is 4. The server uses a u32 to read the size of each data frame,\n      // hence the frame size header is always 4 bytes. It's crucial not to alter this size value,\n      // as the server's logic for frame size reading is based on this fixed 4-byte length.\n      // note:\n      // the size of a u32 is a constant 4 bytes across all platforms that Rust supports.\n      let size = compressed.len() as u32;\n      framed_data.extend_from_slice(&size.to_be_bytes());\n      framed_data.extend_from_slice(&compressed);\n      size_count += size;\n    }\n    event!(\n      tracing::Level::INFO,\n      \"create batch collab with size: {}\",\n      size_count\n    );\n    let body = Body::wrap_stream(stream::once(async { Ok::<_, AppError>(framed_data) }));\n    let resp = self\n      .http_client_with_auth_compress(Method::POST, &url)\n      .await?\n      .timeout(Duration::from_secs(60))\n      .body(body)\n      .send()\n      .await?;\n\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_collab(\n    &self,\n    params: QueryCollabParams,\n  ) -> Result<CollabResponse, AppResponseError> {\n    // 2 seconds, 4 seconds, 8 seconds\n    let retry_strategy = ExponentialBackoff::from_millis(2).factor(1000).take(3);\n    let action = GetCollabAction::new(self.clone(), params);\n    RetryIf::spawn(retry_strategy, action, RetryGetCollabCondition).await\n  }\n\n  pub async fn publish_collabs<Metadata, Data>(\n    &self,\n    workspace_id: &Uuid,\n    items: Vec<PublishCollabItem<Metadata, Data>>,\n  ) -> Result<(), AppResponseError>\n  where\n    Metadata: serde::Serialize + Send + 'static + Unpin,\n    Data: AsRef<[u8]> + Send + 'static + Unpin,\n  {\n    let publish_collab_stream = PublishCollabItemStream::new(items);\n    let url = format!(\"{}/api/workspace/{}/publish\", self.base_url, workspace_id,);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .body(Body::wrap_stream(publish_collab_stream))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn check_if_row_document_collab_exists(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<bool, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{workspace_id}/collab/{object_id}/row-document-collab-exists\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    let info = process_response_data::<AFDatabaseRowDocumentCollabExistenceInfo>(resp).await?;\n    Ok(info.exists)\n  }\n\n  pub async fn get_collab_embed_info(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<AFCollabEmbedInfo, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{workspace_id}/collab/{object_id}/embed-info\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .header(\"Content-Type\", \"application/json\")\n      .send()\n      .await?;\n    process_response_data::<AFCollabEmbedInfo>(resp).await\n  }\n\n  pub async fn batch_get_collab_embed_info(\n    &self,\n    workspace_id: &Uuid,\n    params: Vec<EmbeddedCollabQuery>,\n  ) -> Result<Vec<AFCollabEmbedInfo>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{workspace_id}/collab/embed-info/list\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_data::<RepeatedAFCollabEmbedInfo>(resp)\n      .await\n      .map(|data| data.0)\n  }\n\n  pub async fn force_generate_collab_embeddings(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{workspace_id}/collab/{object_id}/generate-embedding\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn collab_full_sync(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    collab_type: CollabType,\n    doc_state: Vec<u8>,\n    state_vector: Vec<u8>,\n  ) -> Result<Vec<u8>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/v1/{workspace_id}/collab/{object_id}/full-sync\",\n      self.base_url\n    );\n\n    // 3 is default level\n    let doc_state = zstd::encode_all(Cursor::new(doc_state), 3)\n      .map_err(|err| AppError::InvalidRequest(format!(\"Failed to compress text: {}\", err)))?;\n\n    let sv = zstd::encode_all(Cursor::new(state_vector), 3)\n      .map_err(|err| AppError::InvalidRequest(format!(\"Failed to compress text: {}\", err)))?;\n\n    let params = CollabDocStateParams {\n      object_id: object_id.to_string(),\n      collab_type: collab_type.value(),\n      compression: PayloadCompressionType::Zstd as i32,\n      sv,\n      doc_state,\n    };\n\n    let mut encoded_payload = Vec::new();\n    params.encode(&mut encoded_payload).map_err(|err| {\n      AppError::Internal(anyhow!(\"Failed to encode CollabDocStateParams: {}\", err))\n    })?;\n\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .body(Bytes::from(encoded_payload))\n      .send()\n      .await?;\n    if resp.status().is_success() {\n      let body = resp.bytes().await?;\n      let decompressed_body = zstd::decode_all(Cursor::new(body))?;\n      Ok(decompressed_body)\n    } else {\n      process_response_data::<Vec<u8>>(resp).await\n    }\n  }\n}\n\nstruct RetryGetCollabCondition;\nimpl Condition<AppResponseError> for RetryGetCollabCondition {\n  fn should_retry(&mut self, error: &AppResponseError) -> bool {\n    !error.is_record_not_found()\n  }\n}\n\npub struct PublishCollabItemStream<Metadata, Data> {\n  items: Vec<PublishCollabItem<Metadata, Data>>,\n  idx: usize,\n  done: bool,\n}\n\nimpl<Metadata, Data> PublishCollabItemStream<Metadata, Data> {\n  pub fn new(publish_collab_items: Vec<PublishCollabItem<Metadata, Data>>) -> Self {\n    PublishCollabItemStream {\n      items: publish_collab_items,\n      idx: 0,\n      done: false,\n    }\n  }\n}\n\nimpl<Metadata, Data> Stream for PublishCollabItemStream<Metadata, Data>\nwhere\n  Metadata: Serialize + Send + 'static + Unpin,\n  Data: AsRef<[u8]> + Send + 'static + Unpin,\n{\n  type Item = Result<Bytes, std::io::Error>;\n\n  fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let mut self_mut = self.as_mut();\n\n    if self_mut.idx >= self_mut.items.len() {\n      if !self_mut.done {\n        self_mut.done = true;\n        return Poll::Ready(Some(Ok((0_u32).to_le_bytes().to_vec().into())));\n      }\n      return Poll::Ready(None);\n    }\n\n    let item = &self_mut.items[self_mut.idx];\n    match serialize_metadata_data(&item.meta, item.data.as_ref()) {\n      Err(e) => Poll::Ready(Some(Err(e))),\n      Ok(chunk) => {\n        self_mut.idx += 1;\n        Poll::Ready(Some(Ok::<bytes::Bytes, std::io::Error>(chunk)))\n      },\n    }\n  }\n}\n\nfn serialize_metadata_data<Metadata>(m: Metadata, d: &[u8]) -> Result<Bytes, std::io::Error>\nwhere\n  Metadata: Serialize,\n{\n  let meta = serde_json::to_vec(&m)?;\n\n  let mut chunk = Vec::with_capacity(8 + meta.len() + d.len());\n  chunk.extend_from_slice(&(meta.len() as u32).to_le_bytes()); // Encode metadata length\n  chunk.extend_from_slice(&meta);\n  chunk.extend_from_slice(&(d.len() as u32).to_le_bytes()); // Encode data length\n  chunk.extend_from_slice(d);\n\n  Ok(Bytes::from(chunk))\n}\n\npub(crate) struct GetCollabAction {\n  client: Client,\n  params: QueryCollabParams,\n}\n\nimpl GetCollabAction {\n  pub fn new(client: Client, params: QueryCollabParams) -> Self {\n    Self { client, params }\n  }\n}\n\nimpl Action for GetCollabAction {\n  type Future = Pin<Box<dyn Future<Output = Result<Self::Item, Self::Error>> + Send + Sync>>;\n  type Item = CollabResponse;\n  type Error = AppResponseError;\n\n  fn run(&mut self) -> Self::Future {\n    let client = self.client.clone();\n    let params = self.params.clone();\n    let collab_type = self.params.collab_type;\n\n    Box::pin(async move {\n      let url = format!(\n        \"{}/api/workspace/v1/{}/collab/{}\",\n        client.base_url, &params.workspace_id, &params.object_id\n      );\n      let resp = client\n        .http_client_with_auth(Method::GET, &url)\n        .await?\n        .query(&CollabTypeParam { collab_type })\n        .send()\n        .await?;\n      process_response_data::<CollabResponse>(resp).await\n    })\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_file.rs",
    "content": "use crate::ws::{ConnectInfo, WSClientConnectURLProvider, WSClientHttpSender, WSError};\nuse crate::{process_response_data, process_response_error, Client};\n\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse std::fs::metadata;\n\nuse client_api_entity::{\n  CompleteUploadRequest, CreateUploadRequest, CreateUploadResponse, UploadPartResponse,\n};\nuse client_api_entity::{CreateImportTask, CreateImportTaskResponse};\n\nuse percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};\nuse reqwest::{multipart, Body, Method};\nuse shared_entity::response::AppResponseError;\nuse std::path::Path;\n\nuse base64::engine::general_purpose::STANDARD;\nuse base64::Engine;\nuse shared_entity::dto::import_dto::UserImportTask;\nuse tokio::fs::File;\nuse tokio::io::{AsyncBufReadExt, BufReader};\nuse tokio_util::codec::{BytesCodec, FramedRead};\nuse tracing::{error, trace};\nuse uuid::Uuid;\n\nimpl Client {\n  pub async fn create_upload(\n    &self,\n    workspace_id: &Uuid,\n    req: CreateUploadRequest,\n  ) -> Result<CreateUploadResponse, AppResponseError> {\n    trace!(\"create_upload: {}\", req);\n    let url = format!(\n      \"{}/api/file_storage/{workspace_id}/create_upload\",\n      self.base_url\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&req)\n      .send()\n      .await?;\n    process_response_data::<CreateUploadResponse>(resp).await\n  }\n\n  /// Upload a part of a file. The part number should be 1-based.\n  ///\n  /// In Amazon S3, the minimum chunk size for multipart uploads is 5 MB,except for the last part,\n  /// which can be smaller.(https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html)\n  pub async fn upload_part(\n    &self,\n    workspace_id: &Uuid,\n    parent_dir: &str,\n    file_id: &str,\n    upload_id: &str,\n    part_number: i32,\n    body: Vec<u8>,\n  ) -> Result<UploadPartResponse, AppResponseError> {\n    if body.is_empty() {\n      return Err(AppResponseError::from(AppError::InvalidRequest(\n        \"Empty body\".to_string(),\n      )));\n    }\n\n    // Encode the parent directory to ensure it's URL-safe.\n    let parent_dir = utf8_percent_encode(parent_dir, NON_ALPHANUMERIC).to_string();\n    let url = format!(\n            \"{}/api/file_storage/{workspace_id}/upload_part/{parent_dir}/{file_id}/{upload_id}/{part_number}\",\n            self.base_url\n        );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .body(body)\n      .send()\n      .await?;\n    process_response_data::<UploadPartResponse>(resp).await\n  }\n\n  pub async fn complete_upload(\n    &self,\n    workspace_id: &Uuid,\n    req: CompleteUploadRequest,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/file_storage/{}/complete_upload\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&req)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  /// Sends a POST request to import a file to the server.\n  ///\n  /// This function streams the contents of a file located at the provided `file_path`\n  /// as part of a multipart form data request to the server's `/api/import` endpoint.\n  ///\n  /// ### HTTP Request Details:\n  ///\n  /// - **Method:** POST\n  /// - **URL:** `{base_url}/api/import`\n  ///   - The `base_url` is dynamically provided and appended with `/api/import`.\n  ///\n  /// - **Headers:**\n  ///   - `X-Host`: The value of the `base_url` is sent as the host header.\n  ///   - `X-Content-Length`: The size of the file, in bytes, is provided from the file's metadata.\n  ///\n  /// - **Multipart Form:**\n  ///   - The file is sent as a multipart form part:\n  ///     - **Field Name:** The file name derived from the file path or a UUID if unavailable.\n  ///     - **File Content:** The file's content is streamed using `reqwest::Body::wrap_stream`.\n  ///     - **MIME Type:** Guessed from the file's extension using the `mime_guess` crate,\n  ///       defaulting to `application/octet-stream` if undetermined.\n  ///\n  /// ### Parameters:\n  /// - `file_path`: The path to the file to be uploaded.\n  ///   - The file is opened asynchronously and its metadata (like size) is extracted.\n  /// - The MIME type is automatically determined based on the file extension using `mime_guess`.\n  ///\n  pub async fn import_file(&self, file_path: &Path) -> Result<(), AppResponseError> {\n    let md5_base64 = calculate_md5(file_path).await?;\n    let file = File::open(&file_path).await?;\n    let metadata = file.metadata().await?;\n    let file_name = file_path\n      .file_stem()\n      .map(|s| s.to_string_lossy().to_string())\n      .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());\n\n    let stream = FramedRead::new(file, BytesCodec::new());\n    let mime = mime_guess::from_path(file_path)\n      .first_or_octet_stream()\n      .to_string();\n\n    let file_part = multipart::Part::stream(reqwest::Body::wrap_stream(stream))\n      .file_name(file_name.clone())\n      .mime_str(&mime)?;\n\n    let form = multipart::Form::new().part(file_name, file_part);\n    let url = format!(\"{}/api/import\", self.base_url);\n    let mut builder = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .multipart(form);\n\n    // set the host header\n    builder = builder\n      .header(\"X-Host\", self.base_url.clone())\n      .header(\"X-Content-MD5\", md5_base64)\n      .header(\"X-Content-Length\", metadata.len());\n    let resp = builder.send().await?;\n\n    process_response_error(resp).await\n  }\n\n  /// Creates an import task for a file and returns the import task response.\n  ///\n  /// This function initiates an import task by sending a POST request to the\n  /// `/api/import/create` endpoint. The request includes the `workspace_name` derived\n  /// from the provided file's name (or a generated UUID if the file name cannot be determined).\n  ///\n  /// After creating the import task, you should use [Self::upload_import_file] to upload\n  /// the actual file to the presigned URL obtained from the [CreateImportTaskResponse].\n  ///\n  pub async fn create_import(\n    &self,\n    file_path: &Path,\n  ) -> Result<CreateImportTaskResponse, AppResponseError> {\n    let url = format!(\"{}/api/import/create\", self.base_url);\n    let file_name = file_path\n      .file_stem()\n      .map(|s| s.to_string_lossy().to_string())\n      .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());\n\n    let content_length = tokio::fs::metadata(file_path).await?.len();\n    let params = CreateImportTask {\n      workspace_name: file_name.clone(),\n      content_length,\n    };\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .header(\"X-Host\", self.base_url.clone())\n      .json(&params)\n      .send()\n      .await?;\n\n    process_response_data::<CreateImportTaskResponse>(resp).await\n  }\n\n  /// Uploads a file to a specified presigned URL obtained from the import task response.\n  ///\n  /// This function uploads a file to the given presigned URL using an HTTP PUT request.\n  /// The file's metadata is read to determine its size, and the upload stream is created\n  /// and sent to the provided URL. It is recommended to call this function after successfully\n  /// creating an import task using [Self::create_import].\n  ///\n  pub async fn upload_import_file(\n    &self,\n    file_path: &Path,\n    url: &str,\n  ) -> Result<(), AppResponseError> {\n    let file_metadata = metadata(file_path)?;\n    let file_size = file_metadata.len();\n    // Open the file\n    let file = File::open(file_path).await?;\n    let file_stream = FramedRead::new(file, BytesCodec::new());\n    let stream_body = Body::wrap_stream(file_stream);\n    trace!(\"start upload file to s3: {}\", url);\n\n    let client = reqwest::Client::new();\n    let upload_resp = client\n      .put(url)\n      .header(\"Content-Length\", file_size)\n      .header(\"Content-Type\", \"application/zip\")\n      .body(stream_body)\n      .send()\n      .await?;\n\n    if !upload_resp.status().is_success() {\n      error!(\"File upload failed: {:?}\", upload_resp);\n      return Err(AppError::S3ResponseError(\"Cannot upload file to S3\".to_string()).into());\n    }\n\n    Ok(())\n  }\n\n  pub async fn get_import_list(&self) -> Result<UserImportTask, AppResponseError> {\n    let url = format!(\"{}/api/import\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<UserImportTask>(resp).await\n  }\n}\n\n#[async_trait]\nimpl WSClientHttpSender for Client {\n  async fn send_ws_msg(\n    &self,\n    device_id: &str,\n    message: client_websocket::Message,\n  ) -> Result<(), WSError> {\n    self\n      .post_realtime_msg(device_id, message)\n      .await\n      .map_err(|err| WSError::Http(err.to_string()))\n  }\n}\n\n#[async_trait]\nimpl WSClientConnectURLProvider for Client {\n  fn connect_ws_url(&self) -> String {\n    self.ws_addr.clone()\n  }\n\n  async fn connect_info(&self) -> Result<ConnectInfo, WSError> {\n    let conn_info = self\n      .ws_connect_info(true)\n      .await\n      .map_err(|err| WSError::Http(err.to_string()))?;\n    Ok(conn_info)\n  }\n}\n\n/// Calculates the MD5 hash of a file and returns the base64-encoded MD5 digest.\n///\n/// # Arguments\n/// * `file_path` - The path of the file for which the MD5 hash is to be calculated.\n///\n/// # Returns\n/// A `Result` containing the base64-encoded MD5 hash on success, or an error if the file cannot be read.\n///\n/// Asynchronously calculates the MD5 hash of a file using efficient buffer handling and returns it as a base64-encoded string.\n///\n/// # Arguments\n/// * `file_path` - The path to the file to be hashed.\n///\n/// # Returns\n/// Returns a `Result` containing the base64-encoded MD5 hash on success, or an error if the file cannot be read.\npub async fn calculate_md5(file_path: &Path) -> Result<String, anyhow::Error> {\n  let file = File::open(file_path).await?;\n  let mut reader = BufReader::with_capacity(1_000_000, file);\n  let mut context = md5::Context::new();\n  loop {\n    let part = reader.fill_buf().await?;\n    if part.is_empty() {\n      break;\n    }\n\n    context.consume(part);\n    let part_len = part.len();\n    reader.consume(part_len);\n  }\n\n  let md5_hash = context.compute();\n  let md5_base64 = STANDARD.encode(md5_hash.as_ref());\n  Ok(md5_base64)\n}\n"
  },
  {
    "path": "libs/client-api/src/http_guest.rs",
    "content": "use client_api_entity::guest_dto::{\n  RevokeSharedViewAccessRequest, ShareViewWithGuestRequest, SharedViewDetails,\n  SharedViewDetailsRequest, SharedViews,\n};\nuse reqwest::Method;\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nimpl Client {\n  pub async fn share_view_with_guest(\n    &self,\n    workspace_id: &Uuid,\n    params: &ShareViewWithGuestRequest,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/sharing/workspace/{}/view\",\n      self.base_url, workspace_id,\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn revoke_shared_view_access(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &Uuid,\n    params: &RevokeSharedViewAccessRequest,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/sharing/workspace/{}/view/{}/revoke-access\",\n      self.base_url, workspace_id, view_id,\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_shared_views(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<SharedViews, AppResponseError> {\n    let url = format!(\n      \"{}/api/sharing/workspace/{}/view\",\n      self.base_url, workspace_id,\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data(resp).await\n  }\n\n  pub async fn get_shared_view_details(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &Uuid,\n    ancestor_view_ids: &[Uuid],\n  ) -> Result<SharedViewDetails, AppResponseError> {\n    let url = format!(\n      \"{}/api/sharing/workspace/{}/view/{}/access-details\",\n      self.base_url, workspace_id, view_id,\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&SharedViewDetailsRequest {\n        ancestor_view_ids: ancestor_view_ids.to_vec(),\n      })\n      .send()\n      .await?;\n    process_response_data(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_member.rs",
    "content": "use crate::{process_response_data, process_response_error, Client};\nuse client_api_entity::{\n  AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceMember, QueryWorkspaceMember,\n};\nuse reqwest::Method;\nuse shared_entity::dto::workspace_dto::{\n  CreateWorkspaceMembers, WorkspaceMemberChangeset, WorkspaceMemberInvitation, WorkspaceMembers,\n};\nuse shared_entity::response::AppResponseError;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nimpl Client {\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn leave_workspace(&self, workspace_id: &Uuid) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/leave\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&())\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_members(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<AFWorkspaceMember>, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/member\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<AFWorkspaceMember>>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn invite_workspace_members(\n    &self,\n    workspace_id: &Uuid,\n    invitations: Vec<WorkspaceMemberInvitation>,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/invite\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&invitations)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn list_workspace_invitations(\n    &self,\n    status: Option<AFWorkspaceInvitationStatus>,\n  ) -> Result<Vec<AFWorkspaceInvitation>, AppResponseError> {\n    let url = format!(\"{}/api/workspace/invite\", self.base_url);\n    let mut builder = self.http_client_with_auth(Method::GET, &url).await?;\n    if let Some(status) = status {\n      builder = builder.query(&[(\"status\", status)])\n    }\n    let resp = builder.send().await?;\n    process_response_data::<Vec<AFWorkspaceInvitation>>(resp).await\n  }\n\n  pub async fn get_workspace_invitation(\n    &self,\n    invite_uuid: &str,\n  ) -> Result<AFWorkspaceInvitation, AppResponseError> {\n    let url = format!(\"{}/api/workspace/invite/{}\", self.base_url, invite_uuid);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFWorkspaceInvitation>(resp).await\n  }\n\n  pub async fn accept_workspace_invitation(\n    &self,\n    invitation_id: &str,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/accept-invite/{}\",\n      self.base_url, invitation_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&())\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[deprecated(note = \"use invite_workspace_members instead\")]\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn add_workspace_members<T: Into<CreateWorkspaceMembers>, W: AsRef<str>>(\n    &self,\n    workspace_id: W,\n    members: T,\n  ) -> Result<(), AppResponseError> {\n    let members = members.into();\n    let url = format!(\n      \"{}/api/workspace/{}/member\",\n      self.base_url,\n      workspace_id.as_ref()\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&members)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn update_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    changeset: WorkspaceMemberChangeset,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/member\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&changeset)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn remove_workspace_members(\n    &self,\n    workspace_id: &Uuid,\n    member_emails: Vec<String>,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/member\", self.base_url, workspace_id);\n    let payload = WorkspaceMembers::from(member_emails);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .json(&payload)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_member(\n    &self,\n    params: QueryWorkspaceMember,\n  ) -> Result<AFWorkspaceMember, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/member/user/{}\",\n      self.base_url, params.workspace_id, params.uid,\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFWorkspaceMember>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_person.rs",
    "content": "use app_error::AppError;\nuse client_api_entity::{\n  MentionablePerson, MentionablePersons, MentionablePersonsWithAccess, PageMentionUpdate,\n  UserImageAssetSource, WorkspaceMemberProfile,\n};\nuse reqwest::{multipart, Method, StatusCode};\nuse shared_entity::response::AppResponseError;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nimpl Client {\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn list_workspace_mentionable_persons(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<MentionablePersons, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/mentionable-person\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<MentionablePersons>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_mentionable_person(\n    &self,\n    workspace_id: &Uuid,\n    person_id: &Uuid,\n  ) -> Result<MentionablePerson, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/mentionable-person/{}\",\n      self.base_url, workspace_id, person_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<MentionablePerson>(resp).await\n  }\n\n  pub async fn update_workspace_member_profile(\n    &self,\n    workspace_id: &Uuid,\n    updated_profile: &WorkspaceMemberProfile,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/update-member-profile\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(updated_profile)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn list_page_mentionable_persons(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &Uuid,\n  ) -> Result<MentionablePersonsWithAccess, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/mentionable-person-with-access\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<MentionablePersonsWithAccess>(resp).await\n  }\n\n  pub async fn update_page_mention(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &Uuid,\n    page_mention: &PageMentionUpdate,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/page-mention\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(page_mention)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn upload_user_image_asset(\n    &self,\n    local_file_path: &str,\n  ) -> Result<UserImageAssetSource, AppResponseError> {\n    let form = multipart::Form::new()\n      .file(\"asset\", local_file_path)\n      .await?;\n    let url = format!(\"{}/api/user/asset/image\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .multipart(form)\n      .send()\n      .await?;\n    process_response_data(resp).await\n  }\n\n  pub async fn get_user_image_asset(\n    &self,\n    person_id: &Uuid,\n    file_id: &str,\n  ) -> Result<Vec<u8>, AppResponseError> {\n    let url = format!(\n      \"{}/api/user/asset/image/person/{}/file/{}\",\n      self.base_url, person_id, file_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    match resp.status() {\n      StatusCode::OK => Ok(resp.bytes().await?.to_vec()),\n      StatusCode::NOT_FOUND => Err(AppResponseError::from(AppError::RecordNotFound(\n        url.to_owned(),\n      ))),\n      status => {\n        let message = resp\n          .text()\n          .await\n          .unwrap_or_else(|_| \"Unknown error\".to_string());\n        Err(AppResponseError::from(AppError::Unhandled(format!(\n          \"status code: {}, message: {}\",\n          status, message\n        ))))\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_publish.rs",
    "content": "use crate::{process_response_data, process_response_error, Client};\nuse bytes::Bytes;\nuse client_api_entity::publish_dto::DuplicatePublishedPageResponse;\nuse client_api_entity::workspace_dto::{PublishInfoView, PublishedView};\nuse client_api_entity::{workspace_dto::PublishedDuplicate, PublishInfo, UpdatePublishNamespace};\nuse client_api_entity::{\n  CreateGlobalCommentParams, CreateReactionParams, DeleteGlobalCommentParams, DeleteReactionParams,\n  GetReactionQueryParams, GlobalComments, PatchPublishedCollab, PublishInfoMeta, Reactions,\n  UpdateDefaultPublishView,\n};\nuse reqwest::Method;\nuse shared_entity::response::AppResponseError;\nuse tracing::instrument;\nuse uuid::Uuid;\n\n// Publisher API\nimpl Client {\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn list_published_views(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<PublishInfoView>, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/published-info\",\n      self.base_url, workspace_id,\n    );\n\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<PublishInfoView>>(resp).await\n  }\n\n  /// Changes the namespace for the first non-original publish namespace\n  /// or the original publish namespace if not exists.\n  pub async fn set_workspace_publish_namespace(\n    &self,\n    workspace_id: &Uuid,\n    new_namespace: String,\n  ) -> Result<(), AppResponseError> {\n    let old_namespace = self.get_workspace_publish_namespace(workspace_id).await?;\n\n    let url = format!(\n      \"{}/api/workspace/{}/publish-namespace\",\n      self.base_url, workspace_id\n    );\n\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&UpdatePublishNamespace {\n        old_namespace,\n        new_namespace,\n      })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_workspace_publish_namespace(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<String, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/publish-namespace\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<String>(resp).await\n  }\n\n  pub async fn patch_published_collabs(\n    &self,\n    workspace_id: &Uuid,\n    patches: &[PatchPublishedCollab],\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/publish\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::PATCH, &url)\n      .await?\n      .json(patches)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn unpublish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    view_ids: &[Uuid],\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/publish\", self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .json(view_ids)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn create_comment_on_published_view(\n    &self,\n    view_id: &Uuid,\n    comment_content: &str,\n    reply_comment_id: &Option<Uuid>,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/comment\",\n      self.base_url, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&CreateGlobalCommentParams {\n        content: comment_content.to_string(),\n        reply_comment_id: *reply_comment_id,\n      })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_comment_on_published_view(\n    &self,\n    view_id: &Uuid,\n    comment_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/comment\",\n      self.base_url, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .json(&DeleteGlobalCommentParams {\n        comment_id: *comment_id,\n      })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn create_reaction_on_comment(\n    &self,\n    reaction_type: &str,\n    view_id: &Uuid,\n    comment_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/reaction\",\n      self.base_url, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&CreateReactionParams {\n        reaction_type: reaction_type.to_string(),\n        comment_id: *comment_id,\n      })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_reaction_on_comment(\n    &self,\n    reaction_type: &str,\n    view_id: &Uuid,\n    comment_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/reaction\",\n      self.base_url, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .json(&DeleteReactionParams {\n        reaction_type: reaction_type.to_string(),\n        comment_id: *comment_id,\n      })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn set_default_publish_view(\n    &self,\n    workspace_id: &Uuid,\n    view_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/publish-default\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&UpdateDefaultPublishView { view_id })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_default_publish_view(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/publish-default\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_default_publish_view_info(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<PublishInfo, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/publish-default\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<PublishInfo>(resp).await\n  }\n}\n\n// Optional login\nimpl Client {\n  pub async fn get_published_view_comments(\n    &self,\n    view_id: &Uuid,\n  ) -> Result<GlobalComments, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/comment\",\n      self.base_url, view_id\n    );\n    let client = if let Ok(client) = self.http_client_with_auth(Method::GET, &url).await {\n      client\n    } else {\n      self.http_client_without_auth(Method::GET, &url).await?\n    };\n\n    let resp = client.send().await?;\n    process_response_data::<GlobalComments>(resp).await\n  }\n}\n\n// Guest API (no login required)\nimpl Client {\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_published_collab_info(\n    &self,\n    view_id: &Uuid,\n  ) -> Result<PublishInfo, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/v1/published-info/{}\",\n      self.base_url, view_id\n    );\n\n    let resp = self.cloud_client.get(&url).send().await?;\n    process_response_data::<PublishInfo>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_published_outline(\n    &self,\n    publish_namespace: &str,\n  ) -> Result<PublishedView, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-outline/{}\",\n      self.base_url, publish_namespace,\n    );\n\n    let resp = self\n      .cloud_client\n      .get(&url)\n      .send()\n      .await?\n      .error_for_status()?;\n\n    process_response_data::<PublishedView>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_default_published_collab<T>(\n    &self,\n    publish_namespace: &str,\n  ) -> Result<PublishInfoMeta<T>, AppResponseError>\n  where\n    T: serde::de::DeserializeOwned + 'static,\n  {\n    let url = format!(\n      \"{}/api/workspace/published/{}\",\n      self.base_url, publish_namespace,\n    );\n\n    let resp = self\n      .cloud_client\n      .get(&url)\n      .send()\n      .await?\n      .error_for_status()?;\n\n    process_response_data::<PublishInfoMeta<T>>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_published_collab<T>(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<T, AppResponseError>\n  where\n    T: serde::de::DeserializeOwned + 'static,\n  {\n    tracing::debug!(\n      \"get_published_collab: {} {}\",\n      publish_namespace,\n      publish_name\n    );\n    let url = format!(\n      \"{}/api/workspace/v1/published/{}/{}\",\n      self.base_url, publish_namespace, publish_name\n    );\n\n    let resp = self\n      .cloud_client\n      .get(&url)\n      .send()\n      .await?\n      .error_for_status()?;\n\n    process_response_data::<T>(resp).await\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_published_collab_blob(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<Bytes, AppResponseError> {\n    tracing::debug!(\n      \"get_published_collab_blob: {} {}\",\n      publish_namespace,\n      publish_name\n    );\n    let url = format!(\n      \"{}/api/workspace/published/{}/{}/blob\",\n      self.base_url, publish_namespace, publish_name\n    );\n    let resp = self.cloud_client.get(&url).send().await?;\n    let bytes = resp.error_for_status()?.bytes().await?;\n\n    if let Ok(app_err) = serde_json::from_slice::<AppResponseError>(&bytes) {\n      return Err(app_err);\n    }\n\n    Ok(bytes)\n  }\n\n  pub async fn duplicate_published_to_workspace(\n    &self,\n    workspace_id: Uuid,\n    publish_duplicate: &PublishedDuplicate,\n  ) -> Result<DuplicatePublishedPageResponse, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/published-duplicate\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(publish_duplicate)\n      .send()\n      .await?;\n    process_response_data::<DuplicatePublishedPageResponse>(resp).await\n  }\n\n  pub async fn get_published_view_reactions(\n    &self,\n    view_id: &Uuid,\n    comment_id: &Option<Uuid>,\n  ) -> Result<Reactions, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/published-info/{}/reaction\",\n      self.base_url, view_id\n    );\n    let resp = self\n      .cloud_client\n      .get(url)\n      .query(&GetReactionQueryParams {\n        comment_id: *comment_id,\n      })\n      .send()\n      .await?;\n    process_response_data::<Reactions>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_quick_note.rs",
    "content": "use client_api_entity::{\n  CreateQuickNoteParams, ListQuickNotesQueryParams, QuickNote, QuickNotes, UpdateQuickNoteParams,\n};\nuse reqwest::Method;\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nfn quick_note_resources_url(base_url: &str, workspace_id: Uuid) -> String {\n  format!(\"{base_url}/api/workspace/{workspace_id}/quick-note\")\n}\n\nfn quick_note_resource_url(base_url: &str, workspace_id: Uuid, quick_note_id: Uuid) -> String {\n  let quick_note_resources_prefix = quick_note_resources_url(base_url, workspace_id);\n  format!(\"{quick_note_resources_prefix}/{quick_note_id}\")\n}\n\n// Quick Note API\nimpl Client {\n  pub async fn create_quick_note(\n    &self,\n    workspace_id: Uuid,\n    data: Option<serde_json::Value>,\n  ) -> Result<QuickNote, AppResponseError> {\n    let url = quick_note_resources_url(&self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&CreateQuickNoteParams { data })\n      .send()\n      .await?;\n    process_response_data::<QuickNote>(resp).await\n  }\n\n  pub async fn list_quick_notes(\n    &self,\n    workspace_id: Uuid,\n    search_term: Option<String>,\n    offset: Option<i32>,\n    limit: Option<i32>,\n  ) -> Result<QuickNotes, AppResponseError> {\n    let url = quick_note_resources_url(&self.base_url, workspace_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .query(&ListQuickNotesQueryParams {\n        search_term,\n        offset,\n        limit,\n      })\n      .send()\n      .await?;\n    process_response_data::<QuickNotes>(resp).await\n  }\n\n  pub async fn update_quick_note(\n    &self,\n    workspace_id: Uuid,\n    quick_note_id: Uuid,\n    data: serde_json::Value,\n  ) -> Result<(), AppResponseError> {\n    let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&UpdateQuickNoteParams { data })\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_quick_note(\n    &self,\n    workspace_id: Uuid,\n    quick_note_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = quick_note_resource_url(&self.base_url, workspace_id, quick_note_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_search.rs",
    "content": "use app_error::ErrorCode;\nuse reqwest::Method;\nuse shared_entity::dto::search_dto::{\n  SearchDocumentResponseItem, SearchResult, SearchSummaryResult, SummarySearchResultRequest,\n};\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, Client};\n\nimpl Client {\n  /// If `score` is `None`, it will use the score from the server. High score means more relevant.\n  /// score range is 0.0 to 1.0\n  pub async fn search_documents<T: Into<Option<f32>>>(\n    &self,\n    workspace_id: &Uuid,\n    query: &str,\n    limit: u32,\n    preview_size: u32,\n    score: T,\n  ) -> Result<Vec<SearchDocumentResponseItem>, AppResponseError> {\n    let mut raw_query = Vec::with_capacity(4);\n    raw_query.push((\"query\", query.to_string()));\n    raw_query.push((\"limit\", limit.to_string()));\n    raw_query.push((\"preview_size\", preview_size.to_string()));\n\n    if let Some(score_limit) = score.into() {\n      raw_query.push((\"score\", score_limit.to_string()));\n    }\n\n    let query = serde_urlencoded::to_string(raw_query)\n      .map_err(|err| AppResponseError::new(ErrorCode::InvalidRequest, err.to_string()))?;\n\n    let url = format!(\"{}/api/search/{workspace_id}?{query}\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<Vec<SearchDocumentResponseItem>>(resp).await\n  }\n\n  /// High score means more relevant\n  pub async fn generate_search_summary(\n    &self,\n    workspace_id: &Uuid,\n    query: &str,\n    search_results: Vec<SearchResult>,\n  ) -> Result<SearchSummaryResult, AppResponseError> {\n    let payload = SummarySearchResultRequest {\n      query: query.to_string(),\n      search_results,\n      only_context: true,\n    };\n\n    let url = format!(\"{}/api/search/{workspace_id}/summary\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .json(&payload)\n      .send()\n      .await?;\n    process_response_data::<SearchSummaryResult>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_settings.rs",
    "content": "use reqwest::Method;\nuse tracing::{instrument, trace};\n\nuse client_api_entity::AFWorkspaceSettings;\nuse shared_entity::response::AppResponseError;\n\nuse crate::entity::AFWorkspaceSettingsChange;\nuse crate::{process_response_data, Client};\n\nimpl Client {\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn get_workspace_settings<T: AsRef<str>>(\n    &self,\n    workspace_id: T,\n  ) -> Result<AFWorkspaceSettings, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/settings\",\n      self.base_url,\n      workspace_id.as_ref()\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<AFWorkspaceSettings>(resp).await\n  }\n\n  #[instrument(level = \"info\", skip_all, err)]\n  pub async fn update_workspace_settings<T: AsRef<str>>(\n    &self,\n    workspace_id: T,\n    changes: &AFWorkspaceSettingsChange,\n  ) -> Result<AFWorkspaceSettings, AppResponseError> {\n    trace!(\"workspace settings: {:?}\", changes);\n    let url = format!(\n      \"{}/api/workspace/{}/settings\",\n      self.base_url,\n      workspace_id.as_ref()\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&changes)\n      .send()\n      .await?;\n    process_response_data::<AFWorkspaceSettings>(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_template.rs",
    "content": "use client_api_entity::{\n  AccountLink, CreateTemplateCategoryParams, CreateTemplateCreatorParams, CreateTemplateParams,\n  GetTemplateCategoriesQueryParams, GetTemplateCreatorsQueryParams, GetTemplatesQueryParams,\n  Template, TemplateCategories, TemplateCategory, TemplateCategoryType, TemplateCreator,\n  TemplateCreators, TemplateWithPublishInfo, Templates, UpdateTemplateCategoryParams,\n  UpdateTemplateCreatorParams, UpdateTemplateParams,\n};\nuse reqwest::Method;\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nfn template_api_prefix(base_url: &str) -> String {\n  format!(\"{}/api/template-center\", base_url)\n}\n\nfn category_resources_url(base_url: &str) -> String {\n  format!(\"{}/category\", template_api_prefix(base_url))\n}\n\nfn category_resource_url(base_url: &str, category_id: Uuid) -> String {\n  format!(\"{}/{}\", category_resources_url(base_url), category_id)\n}\n\nfn template_creator_resources_url(base_url: &str) -> String {\n  format!(\"{}/creator\", template_api_prefix(base_url))\n}\n\nfn template_creator_resource_url(base_url: &str, creator_id: Uuid) -> String {\n  format!(\n    \"{}/{}\",\n    template_creator_resources_url(base_url),\n    creator_id\n  )\n}\n\nfn template_resources_url(base_url: &str) -> String {\n  format!(\"{}/template\", template_api_prefix(base_url))\n}\n\nfn template_resource_url(base_url: &str, view_id: Uuid) -> String {\n  format!(\"{}/{}\", template_resources_url(base_url), view_id)\n}\n\nimpl Client {\n  pub async fn create_template_category(\n    &self,\n    params: &CreateTemplateCategoryParams,\n  ) -> Result<TemplateCategory, AppResponseError> {\n    let url = category_resources_url(&self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n\n    process_response_data::<TemplateCategory>(resp).await\n  }\n\n  pub async fn get_template_categories(\n    &self,\n    name_contains: Option<&str>,\n    category_type: Option<TemplateCategoryType>,\n  ) -> Result<TemplateCategories, AppResponseError> {\n    let url = category_resources_url(&self.base_url);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .query(&GetTemplateCategoriesQueryParams {\n        name_contains: name_contains.map(|s| s.to_string()),\n        category_type,\n      })\n      .send()\n      .await?;\n    process_response_data::<TemplateCategories>(resp).await\n  }\n\n  pub async fn get_template_category(\n    &self,\n    category_id: Uuid,\n  ) -> Result<TemplateCategory, AppResponseError> {\n    let url = category_resource_url(&self.base_url, category_id);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<TemplateCategory>(resp).await\n  }\n\n  pub async fn delete_template_category(&self, category_id: Uuid) -> Result<(), AppResponseError> {\n    let url = category_resource_url(&self.base_url, category_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_template_category(\n    &self,\n    category_id: Uuid,\n    params: &UpdateTemplateCategoryParams,\n  ) -> Result<TemplateCategory, AppResponseError> {\n    let url = category_resource_url(&self.base_url, category_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n\n    process_response_data::<TemplateCategory>(resp).await\n  }\n\n  pub async fn create_template_creator(\n    &self,\n    name: &str,\n    avatar_url: &str,\n    account_links: Vec<AccountLink>,\n  ) -> Result<TemplateCreator, AppResponseError> {\n    let url = template_creator_resources_url(&self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&CreateTemplateCreatorParams {\n        name: name.to_string(),\n        avatar_url: avatar_url.to_string(),\n        account_links,\n      })\n      .send()\n      .await?;\n\n    process_response_data::<TemplateCreator>(resp).await\n  }\n\n  pub async fn get_template_creators(\n    &self,\n    name_contains: Option<&str>,\n  ) -> Result<TemplateCreators, AppResponseError> {\n    let url = template_creator_resources_url(&self.base_url);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .query(&GetTemplateCreatorsQueryParams {\n        name_contains: name_contains.map(|s| s.to_string()),\n      })\n      .send()\n      .await?;\n    process_response_data::<TemplateCreators>(resp).await\n  }\n\n  pub async fn get_template_creator(\n    &self,\n    creator_id: Uuid,\n  ) -> Result<TemplateCreator, AppResponseError> {\n    let url = template_creator_resource_url(&self.base_url, creator_id);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<TemplateCreator>(resp).await\n  }\n\n  pub async fn delete_template_creator(&self, creator_id: Uuid) -> Result<(), AppResponseError> {\n    let url = template_creator_resource_url(&self.base_url, creator_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_template_creator(\n    &self,\n    creator_id: Uuid,\n    name: &str,\n    avatar_url: &str,\n    account_links: Vec<AccountLink>,\n  ) -> Result<TemplateCreator, AppResponseError> {\n    let url = template_creator_resource_url(&self.base_url, creator_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(&UpdateTemplateCreatorParams {\n        name: name.to_string(),\n        avatar_url: avatar_url.to_string(),\n        account_links,\n      })\n      .send()\n      .await?;\n\n    process_response_data::<TemplateCreator>(resp).await\n  }\n\n  pub async fn create_template(\n    &self,\n    params: &CreateTemplateParams,\n  ) -> Result<Template, AppResponseError> {\n    let url = template_resources_url(&self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n\n    process_response_data::<Template>(resp).await\n  }\n\n  pub async fn get_template(\n    &self,\n    view_id: Uuid,\n  ) -> Result<TemplateWithPublishInfo, AppResponseError> {\n    let url = template_resource_url(&self.base_url, view_id);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_data::<TemplateWithPublishInfo>(resp).await\n  }\n\n  pub async fn get_templates(\n    &self,\n    category_id: Option<Uuid>,\n    is_featured: Option<bool>,\n    is_new_template: Option<bool>,\n    name_contains: Option<String>,\n  ) -> Result<Templates, AppResponseError> {\n    let url = template_resources_url(&self.base_url);\n    let resp = self\n      .http_client_without_auth(Method::GET, &url)\n      .await?\n      .query(&GetTemplatesQueryParams {\n        category_id,\n        is_featured,\n        is_new_template,\n        name_contains,\n      })\n      .send()\n      .await?;\n\n    process_response_data::<Templates>(resp).await\n  }\n\n  pub async fn update_template(\n    &self,\n    view_id: Uuid,\n    params: &UpdateTemplateParams,\n  ) -> Result<Template, AppResponseError> {\n    let url = template_resource_url(&self.base_url, view_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n\n    process_response_data::<Template>(resp).await\n  }\n\n  pub async fn delete_template(&self, view_id: Uuid) -> Result<(), AppResponseError> {\n    let url = template_resource_url(&self.base_url, view_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n\n    process_response_error(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/http_view.rs",
    "content": "use client_api_entity::workspace_dto::{\n  AddRecentPagesParams, AppendBlockToPageParams, CreateFolderViewParams,\n  CreatePageDatabaseViewParams, CreatePageParams, CreateSpaceParams, DuplicatePageParams,\n  FavoritePageParams, MovePageParams, Page, PageCollab, PublishPageParams, Space,\n  UpdatePageExtraParams, UpdatePageIconParams, UpdatePageNameParams, UpdatePageParams,\n  UpdateSpaceParams,\n};\nuse reqwest::Method;\nuse serde_json::json;\nuse shared_entity::response::AppResponseError;\nuse uuid::Uuid;\n\nuse crate::{process_response_data, process_response_error, Client};\n\nimpl Client {\n  pub async fn create_folder_view(\n    &self,\n    workspace_id: Uuid,\n    params: &CreateFolderViewParams,\n  ) -> Result<Page, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/folder-view\",\n      self.base_url, workspace_id,\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_data::<Page>(resp).await\n  }\n\n  pub async fn create_workspace_page_view(\n    &self,\n    workspace_id: Uuid,\n    params: &CreatePageParams,\n  ) -> Result<Page, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/page-view\", self.base_url, workspace_id,);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_data::<Page>(resp).await\n  }\n\n  pub async fn favorite_page_view(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &FavoritePageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/favorite\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn move_workspace_page_view(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &MovePageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/move\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn move_workspace_page_view_to_trash(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/move-to-trash\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn restore_workspace_page_view_from_trash(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/restore-from-trash\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn restore_all_workspace_page_views_from_trash(\n    &self,\n    workspace_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/restore-all-pages-from-trash\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn add_recent_pages(\n    &self,\n    workspace_id: Uuid,\n    params: &AddRecentPagesParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/add-recent-pages\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_workspace_page_view_from_trash(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/trash/{}\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn delete_all_workspace_page_views_from_trash(\n    &self,\n    workspace_id: Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/delete-all-pages-from-trash\",\n      self.base_url, workspace_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_workspace_page_view(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &UpdatePageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PATCH, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn get_workspace_page_view(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<PageCollab, AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::GET, &url)\n      .await?\n      .send()\n      .await?;\n    process_response_data::<PageCollab>(resp).await\n  }\n\n  pub async fn publish_page(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &PublishPageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/publish\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn unpublish_page(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/unpublish\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn create_space(\n    &self,\n    workspace_id: Uuid,\n    params: &CreateSpaceParams,\n  ) -> Result<Space, AppResponseError> {\n    let url = format!(\"{}/api/workspace/{}/space\", self.base_url, workspace_id,);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_data::<Space>(resp).await\n  }\n\n  pub async fn update_space(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &UpdateSpaceParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/space/{}\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::PATCH, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_page_name(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &UpdatePageNameParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/update-name\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_page_icon(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &UpdatePageIconParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/update-icon\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn update_page_extra(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &UpdatePageExtraParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/update-extra\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn remove_page_icon(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/remove-icon\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(&json!({}))\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn append_block_to_page(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &AppendBlockToPageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/append-block\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn create_database_view(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &CreatePageDatabaseViewParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/database-view\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n\n  pub async fn duplicate_view_and_children(\n    &self,\n    workspace_id: Uuid,\n    view_id: &Uuid,\n    params: &DuplicatePageParams,\n  ) -> Result<(), AppResponseError> {\n    let url = format!(\n      \"{}/api/workspace/{}/page-view/{}/duplicate\",\n      self.base_url, workspace_id, view_id\n    );\n    let resp = self\n      .http_client_with_auth(Method::POST, &url)\n      .await?\n      .json(params)\n      .send()\n      .await?;\n    process_response_error(resp).await\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/lib.rs",
    "content": "mod http;\nmod http_ai;\nmod http_billing;\n\nmod http_access_request;\nmod http_blob;\nmod http_collab;\nmod http_guest;\nmod http_member;\nmod http_person;\nmod http_publish;\nmod http_quick_note;\nmod http_search;\nmod http_template;\nmod http_view;\npub use http::*;\n\npub mod collab_sync;\n\nmod http_chat;\nmod http_file;\nmod http_settings;\npub mod notify;\nmod ping;\nmod retry;\n\npub mod log;\npub mod v2;\npub mod ws;\n\npub mod error {\n  pub use shared_entity::response::AppResponseError;\n  pub use shared_entity::response::ErrorCode;\n}\n\n// Export all dto entities that will be used in the frontend application\npub mod entity {\n  #[cfg(not(target_arch = \"wasm32\"))]\n  pub use crate::http_chat::*;\n  pub use appflowy_proto::WorkspaceNotification;\n  pub use client_api_entity::*;\n}\n\n#[cfg(feature = \"template\")]\npub mod template {\n  pub use workspace_template;\n}\n"
  },
  {
    "path": "libs/client-api/src/log.rs",
    "content": "#[macro_export]\nmacro_rules! sync_info {\n    ($($arg:tt)*) => {\n        tracing::info!(target: \"sync_log\", $($arg)*)\n    };\n}\n\n#[macro_export]\nmacro_rules! sync_debug{\n    ($($arg:tt)*) => {\n        tracing::debug!(target: \"sync_log\", $($arg)*)\n    };\n}\n\n#[macro_export]\nmacro_rules! sync_trace {\n    ($($arg:tt)*) => {\n        tracing::trace!(target: \"sync_log\", $($arg)*)\n    };\n}\n\n#[macro_export]\nmacro_rules! sync_error{\n    ($($arg:tt)*) => {\n        tracing::error!(target: \"sync_log\", $($arg)*)\n    }\n}\n\n#[macro_export]\nmacro_rules! sync_warn{\n    ($($arg:tt)*) => {\n        tracing::warn!(target: \"sync_log\", $($arg)*)\n    }\n}\n"
  },
  {
    "path": "libs/client-api/src/notify.rs",
    "content": "use anyhow::Error;\nuse client_api_entity::GotrueTokenResponse;\nuse std::ops::{Deref, DerefMut};\nuse tokio::sync::broadcast::{channel, Receiver, Sender};\nuse tracing::{event, warn};\n\npub type TokenStateReceiver = Receiver<TokenState>;\n\n#[derive(Debug, Clone)]\npub enum TokenState {\n  Refresh,\n  Invalid,\n}\n\npub struct ClientToken {\n  sender: Sender<TokenState>,\n  token: Option<GotrueTokenResponse>,\n}\n\nimpl ClientToken {\n  pub(crate) fn new() -> Self {\n    let (sender, _) = channel(100);\n    Self {\n      sender,\n      token: None,\n    }\n  }\n\n  pub fn is_empty(&self) -> bool {\n    self.token.is_none()\n  }\n\n  pub fn try_get(&self) -> Result<String, Error> {\n    match &self.token {\n      None => Err(anyhow::anyhow!(\"No access token\")),\n      Some(token) => Ok(serde_json::to_string(token)?),\n    }\n  }\n\n  /// Sets a new access token and notifies interested parties of the refresh.\n  ///\n  /// This function updates the internal access token state and sends a `TokenState::Refresh`\n  /// notification to signal that the token has been refreshed.\n  ///\n  /// # Parameters\n  ///\n  /// - `token`: The new `AccessTokenResponse` to be set.\n  pub(crate) fn set(&mut self, new_token: GotrueTokenResponse) {\n    match &self.token {\n      None => {\n        self.token = Some(new_token);\n        let _ = self.sender.send(TokenState::Refresh);\n      },\n      Some(old_token) => {\n        event!(\n          tracing::Level::INFO,\n          \"old token:{}, new token:{}\",\n          old_token,\n          new_token\n        );\n\n        if old_token.expires_at > new_token.expires_at {\n          warn!(\n            \"new token expires_at:{} is less than old token expires_at:{}\",\n            new_token.expires_at, old_token.expires_at\n          );\n        } else {\n          self.token = Some(new_token);\n          tracing::trace!(\"Set new token\");\n          let _ = self.sender.send(TokenState::Refresh);\n        }\n      },\n    };\n  }\n\n  /// Unsets the current access token and notifies receivers of the invalidation.\n  ///\n  /// If there's an existing token, this function clears the internal access token state and sends\n  /// a `TokenState::Invalid` notification to signal that the token has been invalidated.\n  ///\n  #[allow(dead_code)]\n  pub(crate) fn unset(&mut self) {\n    if self.token.is_some() {\n      self.token = None;\n      event!(tracing::Level::DEBUG, \"unset token\");\n      let _ = self.sender.send(TokenState::Invalid);\n    }\n  }\n\n  /// Subscribe to token state change\n  /// Receiver will receive `TokenState::Refresh` when the token is refreshed\n  /// Receiver will receive `TokenState::Invalid` when the token is invalid\n  pub(crate) fn subscribe(&self) -> Receiver<TokenState> {\n    self.sender.subscribe()\n  }\n}\n\nimpl Deref for ClientToken {\n  type Target = Option<GotrueTokenResponse>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.token\n  }\n}\n\nimpl DerefMut for ClientToken {\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    &mut self.token\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/ping.rs",
    "content": "use crate::ws::{ConnectState, ConnectStateNotify};\nuse client_websocket::Message;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::broadcast::Sender;\nuse tokio::sync::mpsc::Receiver;\nuse tokio::sync::Mutex;\n\npub(crate) struct ServerFixIntervalPing {\n  duration: Duration,\n  ping_sender: Option<Sender<Message>>,\n  pong_recv: Option<Receiver<()>>,\n  #[allow(dead_code)]\n  stop_tx: tokio::sync::mpsc::Sender<()>,\n  stop_rx: Option<Receiver<()>>,\n  state: Arc<parking_lot::Mutex<ConnectStateNotify>>,\n  ping_count: Arc<Mutex<u32>>,\n  maximum_ping_count: u32,\n}\n\nimpl ServerFixIntervalPing {\n  pub(crate) fn new(\n    duration: Duration,\n    state: Arc<parking_lot::Mutex<ConnectStateNotify>>,\n    ping_sender: Sender<Message>,\n    pong_recv: Receiver<()>,\n    maximum_ping_count: u32,\n  ) -> Self {\n    let (tx, rx) = tokio::sync::mpsc::channel(1000);\n    Self {\n      duration,\n      stop_tx: tx,\n      stop_rx: Some(rx),\n      state,\n      ping_sender: Some(ping_sender),\n      pong_recv: Some(pong_recv),\n      ping_count: Arc::new(Mutex::new(0)),\n      maximum_ping_count,\n    }\n  }\n\n  pub(crate) async fn stop(&self) {\n    let _ = self.stop_tx.send(()).await;\n  }\n\n  pub(crate) fn run(&mut self) {\n    let mut stop_rx = self.stop_rx.take().expect(\"Only take once\");\n    let mut interval = tokio::time::interval(self.duration);\n    let ping_sender = self.ping_sender.take().expect(\"Only take once\");\n    let mut pong_recv = self.pong_recv.take().expect(\"Only take once\");\n    let weak_ping_count = Arc::downgrade(&self.ping_count);\n    let weak_state = Arc::downgrade(&self.state);\n    let reconnect_per_ping = self.maximum_ping_count;\n    tokio::spawn(async move {\n      loop {\n        tokio::select! {\n          _ = interval.tick() => {\n            // send ping to server\n            // when the ping_sender return error which means the ping_receiver was dropped\n            if  ping_sender.send(Message::Ping(vec![])).is_err() {\n               if let Some(state) =weak_state.upgrade() {\n                 state.lock().set_state(ConnectState::PingTimeout);\n               }\n              break;\n            }\n            if let Some(ping_count) = weak_ping_count.upgrade() {\n              let mut lock = ping_count.lock().await;\n              if *lock >= reconnect_per_ping {\n                if let Some(state) =weak_state.upgrade() {\n                  state.lock().set_state(ConnectState::PingTimeout);\n                }\n              } else {\n                if *lock > 1 {\n                 tracing::trace!(\"ping count: {}\", *lock);\n                }\n                *lock +=1;\n              }\n            }\n          },\n          // pong from server\n          result = pong_recv.recv() => {\n            if result.is_none() {\n              continue;\n            }\n            if let Some(ping_count) = weak_ping_count.upgrade() {\n              let mut lock = ping_count.lock().await;\n              *lock = 0;\n\n              if let Some(state) =weak_state.upgrade() {\n                state.lock().set_state(ConnectState::Connected);\n              }\n            }\n          },\n          _ = stop_rx.recv() => {\n            break;\n          }\n        }\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/retry.rs",
    "content": "use crate::notify::ClientToken;\nuse crate::ws::{\n  ConnectState, ConnectStateNotify, StateNotify, WSClientConnectURLProvider, WSError,\n};\n\nuse app_error::gotrue::GoTrueError;\nuse client_websocket::{connect_async, WebSocketStream};\nuse gotrue::grant::{Grant, RefreshTokenGrant};\nuse parking_lot::RwLock;\nuse std::future::Future;\nuse std::pin::Pin;\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\nuse tokio_retry::strategy::FixedInterval;\nuse tokio_retry::{Action, Condition, RetryIf};\nuse tokio_tungstenite::tungstenite::http::HeaderMap;\nuse tracing::{debug, info, trace};\n\npub(crate) struct RefreshTokenAction {\n  token: Arc<RwLock<ClientToken>>,\n  gotrue_client: Arc<gotrue::api::Client>,\n}\n\nimpl RefreshTokenAction {\n  pub fn new(token: Arc<RwLock<ClientToken>>, gotrue_client: gotrue::api::Client) -> Self {\n    Self {\n      token,\n      gotrue_client: Arc::new(gotrue_client),\n    }\n  }\n}\n\nimpl Action for RefreshTokenAction {\n  type Future = Pin<Box<dyn Future<Output = Result<Self::Item, Self::Error>> + Send + Sync>>;\n  type Item = ();\n  type Error = GoTrueError;\n\n  fn run(&mut self) -> Self::Future {\n    let weak_token = Arc::downgrade(&self.token);\n    let weak_gotrue_client = Arc::downgrade(&self.gotrue_client);\n    Box::pin(async move {\n      if let (Some(token), Some(gotrue_client)) =\n        (weak_token.upgrade(), weak_gotrue_client.upgrade())\n      {\n        let (refresh_token, provider_access_token, provider_refresh_token) = {\n          let mut token_write = token.write();\n          let gotrue_resp_token = token_write.as_mut().ok_or(GoTrueError::NotLoggedIn(\n            \"fail to refresh user token\".to_owned(),\n          ))?;\n          let refresh_token = gotrue_resp_token.refresh_token.as_str().to_owned();\n          let provider_access_token = gotrue_resp_token.provider_access_token.take();\n          let provider_refresh_token = gotrue_resp_token.provider_refresh_token.take();\n          (refresh_token, provider_access_token, provider_refresh_token)\n        };\n\n        let mut access_token_resp = gotrue_client\n          .token(&Grant::RefreshToken(RefreshTokenGrant { refresh_token }))\n          .await?;\n\n        // refresh does not preserve provider token and refresh token\n        // so we need to set it manually to preserve this information\n        access_token_resp.provider_access_token = provider_access_token;\n        access_token_resp.provider_refresh_token = provider_refresh_token;\n\n        token.write().set(access_token_resp);\n      }\n      Ok(())\n    })\n  }\n}\n\npub(crate) struct RefreshTokenRetryCondition;\nimpl Condition<GoTrueError> for RefreshTokenRetryCondition {\n  fn should_retry(&mut self, error: &GoTrueError) -> bool {\n    error.is_network_error()\n  }\n}\n\npub async fn retry_connect(\n  connect_provider: Arc<dyn WSClientConnectURLProvider>,\n  state_notify: Weak<StateNotify>,\n) -> Result<WebSocketStream, WSError> {\n  let stream = RetryIf::spawn(\n    FixedInterval::new(Duration::from_secs(15)),\n    ConnectAction::new(connect_provider),\n    RetryCondition { state_notify },\n  )\n  .await?;\n  Ok(stream)\n}\n\nstruct ConnectAction {\n  connect_provider: Arc<dyn WSClientConnectURLProvider>,\n}\n\nimpl ConnectAction {\n  fn new(connect_provider: Arc<dyn WSClientConnectURLProvider>) -> Self {\n    Self { connect_provider }\n  }\n}\n\nimpl Action for ConnectAction {\n  type Future = Pin<Box<dyn Future<Output = Result<Self::Item, Self::Error>> + Send>>;\n  type Item = WebSocketStream;\n  type Error = WSError;\n\n  fn run(&mut self) -> Self::Future {\n    let connect_provider = self.connect_provider.clone();\n    Box::pin(async move {\n      info!(\"🔵websocket start connecting\");\n      let url = connect_provider.connect_ws_url();\n      let headers: HeaderMap = connect_provider.connect_info().await?.into();\n      trace!(\"websocket url:{}, headers: {:?}\", url, headers);\n      match connect_async(&url, headers).await {\n        Ok(stream) => {\n          info!(\"🟢websocket connect success\");\n          Ok(stream)\n        },\n        Err(e) => Err(e.into()),\n      }\n    })\n  }\n}\n\nstruct RetryCondition {\n  state_notify: Weak<parking_lot::Mutex<ConnectStateNotify>>,\n}\nimpl Condition<WSError> for RetryCondition {\n  fn should_retry(&mut self, error: &WSError) -> bool {\n    if let WSError::AuthError(err) = error {\n      debug!(\"{}, stop retry connect\", err);\n      if let Some(state_notify) = self.state_notify.upgrade() {\n        state_notify.lock().set_state(ConnectState::Unauthorized);\n      }\n      return false;\n    }\n\n    true\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/PROTOCOL.md",
    "content": "# Appflowy client server protocol (version 2.0)\n\nThis is the description of the client server protocol for Appflowy version 2.0.\n\n## Motivation\n\nA new sync protocol is designed with several goals in mind:\n\n- **Simplicity**: unlike the first version, we focus on a minimal set of messages necessary for synchronization.\n  We rely on the underlying transport layer to handle more complex scenarios (i.e. TCP acknowledgement &\n  retransmission, HTTP authorization, packing/unpacking messages into TCP stream etc.).\n- **Sharing**: a single connection should be enough to maintain synchronization between the client and server.\n- **Easy to extend**: it should be easy to add new features to the protocol without breaking existing functionality.\n  For this reason we use an existing serialization format (protobuf) which offers a good balance between performance,\n  binary message size and backward and forward compatibility.\n\nCurrently, the sync v1 protocol has several limitations - like using fixed bincode formatting, which is Rust-specific\nformat that cannot be modified. It also covers a lot of concerns, such as acknowledgements, packing multiple messages\nonto a single WebSocket frame (which prevents us from introducing new fields to the existing messages as messages are\nnot explicitly separated), sending big messages separately over HTTP (which requires separate coordination mechanism\nbetween WS/HTTP requests).\n\n## Overview\n\nThis protocol is designed to be simpler and more flexible, while still providing the necessary functionality. We assume\nthat **messages are processed in order, in which they arrive**. Also, all messages have to be handled. While some level\nof tolerance is possible, in general dropping messages is not allowed, and it can possibly lead to either side dropping\nthe connection.\n\nWe also assume that all data fits into memory - current upper message size limit is set to 10MiB and supported as such\nby the server and native client.\n\nIn sync v2, all communication between the client and server is organized within a scope of a workspace.\nWhile it's possible to have multiple workspaces, the protocol is designed to work with a single connection per\nworkspace.\n\nThe protocol itself communicates over WebSocket and uses protobuf for required binary serialization.\nIn order to establish a connection, client must provide a set of parameters in URI query string:\n\n```\nws://{host}/ws/v2/{workspaceId}?clientId={clientId}&deviceId={deviceId}&token={token}&lastMessageId={lastMessageId}\n```\n\nWhere:\n\n- `{host}` is the server host (ie. `localhost:8000`).\n- `{workspaceId}` is the unique workspace identifier (UUID).\n- `{clientId}` is the unique client (or current session) identifier - a 32bit unsigned integer that is also used between\n  collab folder Y.Doc.clientId. It must be unique for each session (an application instance, running process, browser\n  tab), but it can be reused as long as it's not concurrently used by two processes.\n- `{deviceId}` is the unique device identifier - required for old compatibility with sync v1.\n- `{token}` is the encrypted authentication token - used for user authentication.\n- (optional) `{lastMessageId}` is the last message ID received by the client - if provided, the server will try to\n  inform the client about collabs that were created since that message ID was received.\n\n#### Sync protocol diagram\n\nThis is a basic sequence diagram that illustrates the initial connection and synchronization process between the client\nand server. In practice, the relationship between request and response messages is not strictly enforced and can be\nthought of as an independent message passing rather than request-response model.\n\n```mermaid\nsequenceDiagram\n    participant A as Client A\n    participant S as Server\n    \n    A ->> S: Connect to workspace\n    S -->> A: Connection established\n    \n    A ->> S: SyncRequest\n    S -->> A: Update (state diff)\n    S -->> A: AwarenessUpdate (full state)\n    \n    A ->> S: Update (local to A, incremental)\n    A ->> S: Update (local to A, incremental)\n    A ->> S: AwarenessUpdate (local to A, incremental)\n    S ->> A: Update (remote to A, incremental)\n    S ->> A: AwarenessUpdate (remote to A, incremental)\n```\n\n### Messages\n\nOnce the connection is established, the client and server can exchange messages. That communication is fully\nasynchronous and **DOESN'T** require to follow request-response pattern: some messages can trigger responses but that's\nnot guaranteed.\n\nAt this moment, the protocol supports several message types defined in `./libs/appflowy-proto/proto` folder:\n\n```protobuf\nmessage Message {\n    oneof payload {\n        collab.CollabMessage collab_message = 1;\n        notification.WorkspaceNotification notification = 2;\n    }\n}\n```\n\n`Message` is a root-level message type that all other messages are wrapped in. Currently, it supports two types of\nmessages:\n\n- `collab.CollabMessage` - used for collaborative editing of documents, similar to sync v1.\n- `notification.WorkspaceNotification` - used for workspace-level notifications ie. user profile changes.\n\nCollab sync messages are similar to yjs sync protocol, but they accommodate possibility to support multiple documents\nand leave space for future changes (which original yjs protocol doesn't allow).\n\n#### CollabMessage\n\nAll collab synchronization messages are wrapped in `CollabMessage` message type, which has the following structure:\n\n```protobuf\nmessage CollabMessage {    \n    string object_id = 1;\n    int32 collab_type = 2;\n    oneof data {\n        SyncRequest sync_request = 3;\n        Update update = 4;\n        AwarenessUpdate awareness_update = 5;\n        AccessChanged access_changed = 6;\n    }\n}\n\n```\n\nWhere:\n\n- `object_id` is the unique identifier of the document (collab) that this message is related to. It's a UUID formatted\n  as a string (i.e. `a573443f-d5c4-4533-a2ac-060af7e0`). While internally all collabs use native representation of UUID,\n  for transport purposes it's serialized as a string (as it's simpler to interop across languages).\n- `collab_type` is integer representing the type of the collab. It's required for compatibility purposes with existing\n  system, possibly can be made obsolete in the future. Currently, this can be one of:\n    - `0` - **Document** - a regular rich text document that can be edited collaboratively.\n    - `1` - **Database** - database collab, similar to tabular data, which can contain multiple rows.\n    - `2` - **WorkspaceDatabase** - workspace database collab.\n    - `3` - **Folder** - a workspace folder collab, which in appflowy is used to maintain page tree hierarchy.\n    - `4` - **DatabaseRow** - a single row in the **Database** collab, which can be edited collaboratively or promoted\n      to Document-like view.\n    - `5` - **UserAwareness** - a special collab type used to maintain user information i.e. profile data or reminders.\n    - **Unknown** - used a placeholder for documents of different types.\n\nThe `data` field is one of the possible messages used in the collab synchronization process.\n\n#### SyncRequest\n\n`SyncRequest` is a message that can be sent by either client or server to request synchronization of the collab.\n\n```protobuf\nmessage SyncRequest {\n    Rid last_message_id = 1;\n    bytes state_vector = 2;\n}\n```\n\nWhere:\n\n- (optional) `last_message_id` is the identifier ([Rid](#rid)) of the last update received from the server corresponding\n  to this collab. If provided, the server will try to send only updates that were made after this message ID.\n- `state_vector` is the Yjs/Yrs state vector encoded using lib0 v1 encoding. State vector describes the current document\n  state as understood by Yjs. This information is used by the server to determine the difference between current state\n  on the server and the client.\n\nAs a response to `SyncRequest`, the server/client should send a `CollabMessage` with `Update` data type, which contains\nthe actual Yjs document data that can be applied to Yjs Doc. Another message that can be sent in response is\n`AwarenessUpdate`, which contains the full latest known awareness state.\n\nAdditionally, either of the server/client can send their own `SyncRequest` at any time to request the latest state known\nby the client/server. This can happen when either side will determine that it has some missing updates known to the\nother side.\n\nTechnically, there's no rule saying that `SyncRequest` must be replied with a single `Update`: since incremental updates\nare send without request, as they come to the server, the server can send multiple `Update` messages in response to a\nsingle `SyncRequest`.\n\n#### Update\n\nUpdate is a message that contains the actual Yjs document data that can be applied to Yjs Doc. This data can contain\neither an incremental update change produces and send without request, or a full document state or difference between\ndocument states requested by `SyncRequest`.\n\n```protobuf\nmessage Update {\n    Rid message_id = 1;\n    uint32 flags = 2;\n    bytes payload = 3;\n}\n```\n\nWhere:\n\n- `message_id` is the identifier ([Rid](#rid)) of this update message. Since message ids are only generated by the\n  server, client can omit this field.\n- `flags` is a number that can be used to indicate special properties of the update. A multiple flags can be\n  combined in a single value using bitwise OR operation. Currently supported flags:\n    - `0x00` - update is encoded using lib0 v1 encoding, which is the default encoding used by Yjs.\n    - `0x01` - update is encoded using lib0 v2 encoding, which is a more efficient encoding that can be used for\n      larger updates, but slower and bigger for smaller (i.e. incremental) updates.\n    - In the future other flags may be added i.e. type of compression used.\n\nServer sends all incremental updates related to the workspace, that current connection is established for, as this\nallows us:\n\n1. To scale to any arbitrary number of collabs.\n2. With the assumption that the client will eventually want to synchronize the entire\n   workspace (which is advantageous for offline & native apps) use without explicitly requesting for collabs one by one.\n3. Even with dozens of active users, it's still cheaper to send all updates (that may be eventually needed anyway) than\n   to iterate stream of unmerged updates for each collab separately.\n\n#### AwarenessUpdate\n\n`AwarenessUpdate` is a message that contains information about the current user Yjs awareness state. This awareness\nstate contains temporary information about the user i.e. current cursor position, color, username etc.\n\n```protobuf\nmessage AwarenessUpdate {\n    bytes payload = 1;\n}\n```\n\nWhere:\n\n- `payload` is the awareness state encoded using lib0 v1 encoding.\n\nAwareness updates don't depend on each other and can be sent at any time by either client or server. By default, when\nthe client sends a `SyncRequest`, server will also reply with the latest awareness state for the collab if such exists.\nThe following awareness updates can also be incremental (they contain only changes to the awareness state, not a full\nstate).\n\n#### AccessChanged\n\n`AccessChanged` is a message that contains information about the access rights changes for the collab. This message can\nonly be sent by the server.\n\n```protobuf\nmessage AccessChanged {\n    bool can_read = 1;\n    bool can_write = 2;\n    int32 reason = 3;\n}\n```\n\nWhere:\n\n- `can_read` is a boolean value indicating whether the user has read access to the collab.\n- `can_write` is a boolean value indicating whether the user has write access to the collab.\n- `reason` is an integer value indicating the reason for the access change. This can be used to determine\n  whether the access was changed by the user, by the server or due to some other reason (i.e. document deletion).\n  Currently supported values:\n    - `0` - **PermissionDenied** - the user doesn't have permission to access the collab.\n    - `1` - **ObjectDeleted** - the collab was deleted and the user no longer has access to it.\n\n> In the future we also want to propose `Reset` message that would carry a full document state, whose goal is to force\n> the client to reset its own document state to the one provided.\n\n#### RID\n\nAll updates are stored in the Redis stream before they are merged into main document - this is because\nthe change of entire document is a heavy task, while individual updates (eg. text edits triggered by keyboard strokes)\nare very small and very frequent. Streams are used on per workspace basis, which allows us to scale to any number of\ndocuments, track the usage according to specific user or organization etc.\n\n`Rid` (short for **R**edis stream **ID**) is a message ID used to identify messages in the Redis stream. It's used only\nin the context of the document update. Each rid can be represented as `{timestamp}-{sequence}` where:\n\n- `{timestamp}` is the UNIX timestamp in milliseconds when the message was received by Redis.\n- `{sequence}` is a monotonically increasing sequence number used to guarantee uniqueness of the message ID. For most of\n  the time it's equal to 0.\n\nEvery collab update received from the server has a `Rid` field. It can be used for several potential features:\n\n1. A `timestamp` field can be used by the client to filter updates by date.\n2. A `timestamp` field can also be used to mark the last time when the collab update **was made public**. It's not the\n   same as when the document update was made though, since timestamp is assigned by the server for the first time it\n   received a given update, while the updates themselves could have been made by the client while offline.\n\n## Using protobuf in the web client\n\nUsing protobuf in the web client requires some additional steps, as the browser doesn't support native protobuf. We can\nbridge this via `protobufjs-cli`:\n\n```bash\npnpm install -g protobufjs-cli\n```\n\nOnce this is done we need to generate the JavaScript code from the protobuf definitions, available under\n`./libs/appflowy-proto/proto` path:\n\n```bash\n# generate JavaScript code from protobuf definitions\npbjs -t status-module -w es6 -o ./path/to/output.js ./libs/appflowy-proto/proto/messages.proto\n\n# generate TypeScript definitions from protobuf definitions\npbts -o ./path/to/output.d.ts ./path/to/output.js\n```\n\nOnce this is done, we can import the generated code in our web client:\n\n```javascript\nimport {messages} from './path/to/output';\nimport * as Y from 'yjs';\nimport * as awarenessProtocol from 'y-protocols/awareness.js'\n\nconst ws = new WebSocket(`wss://localhost:8000/ws/v2/{workspaceId}?clientId={clientId}&deviceId={deviceId}&token={token}`);\nws.binaryType = 'arraybuffer';\nws.onmessage = (event) => {\n    const msg = messages.Message.decode(new Uint8Array(event.data));\n    if (msg.collabMessage) {\n        const origin = 'remote'\n        const msg = msg.collabMessage;\n        const {ydoc, awareness} = getYDoc(msg.objectId); // get Y.Doc instance by objectId\n        if (msg.syncRequest) {\n            // handle sync request\n            const diff = Y.encodeStateAsUpdateV2(ydoc, msg.syncRequest.stateVector);\n            ws.send(messages.Message.encode({\n                collabMessage: {\n                    objectId: msg.objectId,\n                    collabType: msg.collabType,\n                    update: {\n                        flags: 0x01, // lib0 v2 encoding\n                        payload: diff,\n                    }\n                }\n            }).finish());\n        } else if (msg.update) {\n            // handle update message\n            const update = msg.update.payload;\n            // check if update is encoded using lib0 v2 encoding\n            const isV2 = msg.update.flags & 0x01 !== 0;\n            if (isV2) {\n                // apply decoded update to Y.Doc\n                Y.applyUpdateV2(ydoc, decodedUpdate, origin);\n            } else {\n                // apply update directly if it's in lib0 v1 encoding\n                Y.applyUpdate(ydoc, update, origin);\n            }\n        } else if (msg.awarenessUpdate) {\n            // handle awareness update\n            awarenessProtocol.applyAwarenessUpdate(awareness, msg.awarenessUpdate.payload, origin);\n        } else if (msg.accessChanged) {\n            // handle access changed message\n        }\n    } else if (msg.notification) {\n        // handle notification\n    }\n};\n\n// sending a message\nlet syncReq = messages.Message.encode({\n    collabMessage: {\n        objectId: ydoc.guid, // ie. 'a573443f-d5c4-4533-a2ac-060af7e0',\n        collabType: 0,\n        syncRequest: {\n            stateVector: Y.encodeStateVector(ydoc),\n        }\n    }\n}).finish();\nws.send(syncReq);\n\n// sending incremental updates\nydoc.on('update', (update, origin) => {\n    if (isRemote(update)) {\n        // don't resend updates received from the server\n        return;\n    }\n    let updateMsg = messages.Message.encode({\n        collabMessage: {\n            objectId: ydoc.guid,\n            collabType: 0, // Document collab type\n            update: {\n                flags: 0x00, // lib0 v1 encoding\n                payload: update,\n            }\n        }\n    }).finish();\n    ws.send(updateMsg);\n})\n```"
  },
  {
    "path": "libs/client-api/src/v2/actor.rs",
    "content": "use crate::v2::compactor::ChannelReceiverCompactor;\nuse crate::v2::controller::{ConnectionStatus, DisconnectedReason, Options};\nuse crate::v2::db::Db;\nuse crate::v2::ObjectId;\nuse crate::{sync_debug, sync_error, sync_info, sync_trace, sync_warn};\nuse app_error::AppError;\nuse appflowy_proto::{\n  AccessChangedReason, ClientMessage, Rid, ServerMessage, UpdateFlags, WorkspaceNotification,\n};\nuse arc_swap::ArcSwap;\nuse bytes::BytesMut;\nuse client_api_entity::CollabType;\nuse collab::core::collab_state::{InitState, State, SyncState};\nuse collab::preclude::Collab;\nuse collab_rt_protocol::{CollabRef, WeakCollabRef};\nuse dashmap::DashMap;\nuse futures_util::stream::{SplitSink, SplitStream};\nuse futures_util::{SinkExt, StreamExt};\nuse shared_entity::response::AppResponseError;\nuse std::collections::HashMap;\nuse std::fmt::{Display, Formatter, Write};\nuse std::hash::{Hash, Hasher};\nuse std::sync::atomic::AtomicBool;\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\nuse tokio::select;\nuse tokio::sync::mpsc::UnboundedSender;\nuse tokio::sync::Mutex;\nuse tokio::time::{timeout, MissedTickBehavior};\nuse tokio_tungstenite::tungstenite::client::IntoClientRequest;\nuse tokio_tungstenite::tungstenite::protocol::WebSocketConfig;\nuse tokio_tungstenite::tungstenite::Message;\nuse tokio_tungstenite::{connect_async_with_config, MaybeTlsStream};\nuse tokio_util::sync::CancellationToken;\nuse tracing::{error, instrument};\nuse uuid::Uuid;\nuse yrs::block::ClientID;\nuse yrs::sync::{Awareness, AwarenessUpdate};\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::Encode;\nuse yrs::{ReadTxn, StateVector, Transact, Transaction, Update, WriteTxn};\n\npub(super) struct WorkspaceControllerActor {\n  options: Options,\n  status_rx: tokio::sync::watch::Receiver<ConnectionStatus>,\n  status_tx: tokio::sync::watch::Sender<ConnectionStatus>,\n  mailbox: WorkspaceControllerMailbox,\n  last_message_id: Arc<ArcSwap<Rid>>,\n  /// Cache for collabs actually existing in the memory, along with their type.\n  cache: Arc<DashMap<ObjectId, CachedCollab>>,\n  /// Persistent database handle.\n  db: Db,\n  notification_tx: tokio::sync::broadcast::Sender<WorkspaceNotification>,\n  /// Used to record recently changed collabs\n  changed_collab_sender: tokio::sync::broadcast::Sender<ChangedCollab>,\n  #[cfg(debug_assertions)]\n  pub skip_realtime_message: AtomicBool,\n}\n\nimpl WorkspaceControllerActor {\n  const PING_INTERVAL: Duration = Duration::from_secs(4);\n  const PING_TIMEOUT: Duration = Duration::from_secs(20);\n  const REMOTE_ORIGIN: &'static str = \"af\";\n\n  pub fn new(db: Db, options: Options, last_message_id: Rid) -> Arc<Self> {\n    let (changed_collab_sender, _) = tokio::sync::broadcast::channel(10);\n    let (status_tx, status_rx) = tokio::sync::watch::channel(ConnectionStatus::default());\n    let (message_tx, message_rx) = tokio::sync::mpsc::unbounded_channel();\n    let (notification_tx, _) = tokio::sync::broadcast::channel(100);\n    let actor = Arc::new(WorkspaceControllerActor {\n      options,\n      status_rx,\n      status_tx,\n      mailbox: message_tx,\n      last_message_id: Arc::new(ArcSwap::new(last_message_id.into())),\n      cache: Arc::new(DashMap::new()),\n      db,\n      #[cfg(debug_assertions)]\n      skip_realtime_message: AtomicBool::new(false),\n      notification_tx,\n      changed_collab_sender,\n    });\n    tokio::spawn(Self::actor_loop(\n      Arc::downgrade(&actor),\n      ChannelReceiverCompactor::new(message_rx),\n    ));\n    actor\n  }\n\n  pub fn subscribe_changed_collab(&self) -> tokio::sync::broadcast::Receiver<ChangedCollab> {\n    self.changed_collab_sender.subscribe()\n  }\n\n  pub fn subscribe_notification(&self) -> tokio::sync::broadcast::Receiver<WorkspaceNotification> {\n    self.notification_tx.subscribe()\n  }\n\n  pub fn client_id(&self) -> ClientID {\n    self.db.client_id()\n  }\n\n  pub fn workspace_id(&self) -> &Uuid {\n    &self.options.workspace_id\n  }\n\n  pub fn trigger(&self, action: WorkspaceAction) {\n    if let Err(err) = self.mailbox.send(action) {\n      error!(\"failed to send action to actor: {}\", err);\n    }\n  }\n\n  pub fn status_channel(&self) -> &tokio::sync::watch::Receiver<ConnectionStatus> {\n    &self.status_rx\n  }\n\n  pub fn get_collab(&self, object_id: &ObjectId) -> Option<CollabRef> {\n    self.cache.get(object_id)?.upgrade()\n  }\n\n  /// Remove colla from the cache\n  pub fn remove_collab(&self, object_id: &ObjectId) {\n    self.cache.remove(object_id);\n  }\n\n  /// Remove collab from cache and delete the object from db\n  pub fn delete_collab(&self, object_id: &ObjectId) -> anyhow::Result<()> {\n    self.cache.remove(object_id);\n    self.db.remove_doc(object_id)?;\n    Ok(())\n  }\n\n  pub fn last_message_id(&self) -> Rid {\n    *self.last_message_id.load_full()\n  }\n\n  ///\n  /// Binds a collaboration object to the actor and loads its data if needed.\n  /// This function sets up the necessary callbacks and observers to handle\n  /// collaboration updates and awareness changes.\n  ///\n  /// # Arguments\n  ///\n  /// * `actor`: Reference to the workspace controller actor managing the collaboration\n  /// * `collab_ref`: Reference to the collaboration object to be bound\n  /// * `collab_type`: The type of the collaboration (document, folder, etc.)\n  ///\n  pub async fn bind_and_cache_collab_ref(\n    actor: &Arc<Self>,\n    collab_ref: &CollabRef,\n    collab_type: CollabType,\n  ) -> anyhow::Result<()> {\n    let mut collab = collab_ref.write().await;\n    let collab = (*collab).borrow_mut();\n    let object_id: ObjectId = collab.object_id().parse()?;\n\n    let entry = actor.cache.entry(object_id);\n    Self::bind(actor, collab, collab_type)?;\n    entry.insert(CachedCollab::new(Arc::downgrade(collab_ref), collab_type));\n    Ok(())\n  }\n\n  pub fn cache_collab_ref(\n    &self,\n    object_id: ObjectId,\n    collab_ref: &CollabRef,\n    collab_type: CollabType,\n  ) {\n    self.cache.insert(\n      object_id,\n      CachedCollab::new(Arc::downgrade(collab_ref), collab_type),\n    );\n  }\n\n  pub async fn unbind(&self, object_id: &ObjectId) {\n    if let Some(collab) = self.get_collab(object_id) {\n      sync_trace!(\"unbind collab {}/{}\", self.workspace_id(), object_id);\n      let mut guard = collab.write().await;\n      let collab = (*guard).borrow_mut();\n      let awareness = collab.get_awareness();\n      if let Err(err) = unobserve_update(object_id, awareness) {\n        sync_error!(\n          \"failed to unobserve {}/{} update: {}\",\n          self.workspace_id(),\n          object_id,\n          err\n        );\n      }\n\n      sync_trace!(\n        \"unobserve awareness for collab {}/{}\",\n        self.workspace_id(),\n        object_id\n      );\n      unobserve_awareness(awareness);\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all, err)]\n  pub fn bind(\n    actor: &Arc<Self>,\n    collab: &mut Collab,\n    collab_type: CollabType,\n  ) -> anyhow::Result<()> {\n    let object_id: ObjectId = collab.object_id().parse()?;\n    let client_id = actor.db.client_id();\n    collab_type.validate_require_data(collab)?;\n    sync_info!(\n      \"binding collab {}/{}/{} for client:{}\",\n      actor.workspace_id(),\n      object_id,\n      collab_type,\n      client_id,\n    );\n\n    let sync_state = collab.get_state().clone();\n    let last_message_id = actor.last_message_id.clone();\n    sync_state.set_init_state(InitState::Loading);\n\n    sync_trace!(\"init collab {}/{}\", actor.workspace_id(), object_id);\n    if !actor.db.init_collab(&object_id, collab, &collab_type)? {\n      tracing::debug!(\"loading collab {} from local db\", object_id);\n      actor.db.load(collab, true)?;\n    }\n    sync_state.set_init_state(InitState::Initialized);\n    let client_id = actor.db.client_id();\n    sync_state.set_sync_state(SyncState::InitSyncBegin);\n\n    // Register callback on this collab to observe incoming updates\n    observe_update(\n      collab_type,\n      object_id,\n      sync_state,\n      last_message_id,\n      Arc::downgrade(actor),\n      client_id,\n      collab.get_awareness(),\n    )?;\n\n    // Only observe awareness changed if the collab supports it\n    let sync_awareness = collab_type.awareness_enabled() || cfg!(debug_assertions);\n    if sync_awareness {\n      let awareness = collab.get_awareness();\n      observe_awareness(actor, collab_type, object_id, client_id, awareness);\n      actor.publish_awareness(object_id, collab_type, awareness.update()?);\n    }\n\n    actor.publish_manifest(object_id, collab, collab_type);\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub(crate) fn set_connection_status(&self, status: ConnectionStatus) {\n    sync_info!(\"set connection status: {:?}\", status);\n    self.status_tx.send_replace(status);\n  }\n\n  async fn ping(&self) -> anyhow::Result<()> {\n    if let Some(conn) = self.ws_sink() {\n      let mut lock = conn.lock().await;\n      lock.send(Message::Ping(Vec::new())).await?;\n      lock.flush().await?;\n    }\n    Ok(())\n  }\n\n  async fn actor_loop(\n    weak_ref: Weak<WorkspaceControllerActor>,\n    mut receiver: ChannelReceiverCompactor,\n  ) {\n    let mut keep_alive = tokio::time::interval(Self::PING_INTERVAL);\n    keep_alive.set_missed_tick_behavior(MissedTickBehavior::Delay);\n    loop {\n      select! {\n        action = receiver.recv() => {\n          match action {\n            None => break,\n            Some(action) => {\n              if let Some(actor) = weak_ref.upgrade() {\n                Self::handle_action(&actor, action).await;\n              }\n            }\n          }\n        }\n        _ = keep_alive.tick() => {\n          // we didn't receive any message for some time, so we send a ping to keep connection alive\n          let actor = match weak_ref.upgrade() {\n            Some(actor) => actor,\n            None => break, // controller dropped\n          };\n\n          let result = timeout(Self::PING_TIMEOUT, actor.ping()).await;\n          match result {\n            Ok(Ok(_)) => sync_trace!(\"sent ping successfully\"),\n            Ok(Err(err)) => {\n              sync_error!(\"fail to send ping: {:?}\", err);\n              actor.set_connection_status(ConnectionStatus::Disconnected {\n                reason: Some(DisconnectedReason::Unexpected(\"Ping send failed\".into())),\n              });\n            },\n            Err(_) => {\n              sync_error!(\"ping timeout after {:?}\", Self::PING_TIMEOUT);\n              actor.set_connection_status(ConnectionStatus::Disconnected {\n                reason: Some(DisconnectedReason::Unexpected(\"Ping timeout\".into())),\n              });\n            }\n          }\n        }\n      }\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn handle_action(actor: &Arc<Self>, action: WorkspaceAction) {\n    let id = actor.db.client_id();\n    sync_trace!(\"[{}] action {:?}\", id, action);\n    match action {\n      WorkspaceAction::Connect { ack, access_token } => {\n        sync_debug!(\n          \"[{}] start websocket connect with token: {}\",\n          id,\n          access_token\n        );\n        match Self::handle_connect(actor, access_token).await {\n          Ok(_) => {\n            let _ = ack.send(Ok(()));\n          },\n          Err(err) => {\n            sync_error!(\"[{}] failed to connect: {}\", id, err);\n            let reason = DisconnectedReason::from(err.clone());\n            actor.set_connection_status(ConnectionStatus::Disconnected {\n              reason: Some(reason),\n            });\n            let _ = ack.send(Err(err));\n          },\n        }\n      },\n      WorkspaceAction::Disconnect(ack) => match actor.handle_disconnect().await {\n        Ok(_) => {\n          sync_trace!(\"[{}] disconnected\", id);\n          actor.set_connection_status(ConnectionStatus::Disconnected {\n            reason: Some(DisconnectedReason::UserDisconnect(\n              \"User disconnect successfully\".into(),\n            )),\n          });\n          let _ = ack.send(Ok(()));\n        },\n        Err(err) => {\n          sync_warn!(\"[{}] failed to disconnect: {}\", id, err);\n          actor.set_connection_status(ConnectionStatus::Disconnected {\n            reason: Some(DisconnectedReason::UserDisconnect(err.to_string().into())),\n          });\n          let _ = ack.send(Err(err));\n        },\n      },\n      WorkspaceAction::Send(msg, source) => {\n        if let Err(err) = actor.handle_send(msg, source).await {\n          sync_error!(\"[{}] failed to send client message: {}\", id, err);\n          actor.set_connection_status(ConnectionStatus::Disconnected {\n            reason: Some(DisconnectedReason::Unexpected(err.to_string().into())),\n          });\n        }\n      },\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn handle_send(&self, msg: ClientMessage, source: ActionSource) -> anyhow::Result<()> {\n    if let ClientMessage::Update {\n      object_id,\n      flags,\n      update,\n      ..\n    } = &msg\n    {\n      let rid = source.into();\n      // persist\n      match flags {\n        UpdateFlags::Lib0v1 => self.db.save_update(object_id, rid, update, source),\n        UpdateFlags::Lib0v2 => {\n          let update_v1 = Update::decode_v2(update)?.encode_v1();\n          self.db.save_update(object_id, rid, &update_v1, source)\n        },\n      }?;\n    };\n    if let ActionSource::Local = source {\n      if let Err(err) = self.send_message(msg).await {\n        sync_error!(\"Failed to send message: {}\", err);\n        self.set_connection_status(ConnectionStatus::Disconnected {\n          reason: Some(DisconnectedReason::Unexpected(err.to_string().into())),\n        });\n        return Err(err);\n      }\n    }\n    Ok(())\n  }\n\n  async fn send_message(&self, msg: ClientMessage) -> anyhow::Result<()> {\n    let sync_state = match &msg {\n      ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        ..\n      } => {\n        if !matches!(collab_type, CollabType::DatabaseRow) {\n          sync_info!(\n            \"[{}] sending {}/{} manifest to remote\",\n            self.db.client_id(),\n            object_id,\n            collab_type\n          );\n        }\n        Some((*object_id, SyncState::InitSyncBegin))\n      },\n      ClientMessage::Update { object_id, .. } => Some((*object_id, SyncState::SyncFinished)),\n      ClientMessage::AwarenessUpdate { .. } => None,\n    };\n    if let Some(sink) = self.ws_sink() {\n      sync_debug!(\"[{}] sending message: {:?}\", self.db.client_id(), msg);\n      {\n        let bytes = msg.into_bytes()?;\n        let mut sink = sink.lock().await;\n        sink.send(Message::Binary(bytes)).await?;\n      }\n      if let Some((object_id, sync_state)) = sync_state {\n        self.set_collab_sync_state(&object_id, sync_state).await;\n      }\n    } else {\n      // When the connection is disconnected, we need to check if the reason is retriable.\n      // If the current disconnection status is retriable, we trigger a reconnection attempt.\n      // Once reconnection is initiated, additional reconnection triggers will be ignored\n      // until the current reconnection process completes.\n      let should_retry = self\n        .status_rx\n        .borrow()\n        .disconnected_reason()\n        .as_ref()\n        .map(|v| v.retriable_when_editing())\n        .unwrap_or(false);\n\n      if should_retry {\n        self.set_connection_status(ConnectionStatus::StartReconnect);\n      }\n      sync_trace!(\"Skip sending message: sink\");\n    }\n    Ok(())\n  }\n\n  async fn set_collab_sync_state(&self, object_id: &ObjectId, sync_state: SyncState) {\n    if let Some(collab_ref) = self.get_collab(object_id) {\n      let lock = collab_ref.read().await;\n      let collab = lock.borrow();\n      collab.set_sync_state(sync_state);\n    }\n  }\n\n  #[instrument(level = \"debug\", skip_all, err)]\n  pub(crate) async fn handle_connect(\n    actor: &Arc<Self>,\n    access_token: String,\n  ) -> Result<(), AppResponseError> {\n    match &*actor.status_rx.borrow() {\n      ConnectionStatus::Connecting { .. } => {\n        sync_info!(\n          \"[{}] websocket already connecting, skipping connect\",\n          actor.db.client_id()\n        );\n        return Ok(());\n      },\n      ConnectionStatus::Connected { .. } => {\n        sync_info!(\n          \"[{}] websocket already connected, skipping connect\",\n          actor.db.client_id()\n        );\n        return Ok(());\n      },\n      ConnectionStatus::Disconnected { .. } => {},\n      ConnectionStatus::StartReconnect => {},\n    }\n\n    let cancel = CancellationToken::new();\n    actor.set_connection_status(ConnectionStatus::Connecting {\n      cancel: cancel.clone(),\n    });\n\n    let last_message_id = actor.last_message_id.load_full();\n    let client_id = actor.db.client_id();\n    let result = Self::establish_connection(\n      &actor.options,\n      client_id,\n      &last_message_id,\n      cancel.clone(),\n      access_token,\n    )\n    .await?;\n\n    match result {\n      None => {\n        sync_info!(\"[{}] connection established failed\", actor.db.client_id());\n        actor.set_connection_status(ConnectionStatus::Disconnected {\n          reason: Some(DisconnectedReason::Unexpected(\n            \"Establish connect failed\".into(),\n          )),\n        });\n      },\n      Some(connection) => {\n        sync_info!(\"[{}] connected to {}\", client_id, actor.options.url);\n        let (sink, stream) = connection.split();\n        let sink = Arc::new(Mutex::new(sink));\n        actor.set_connection_status(ConnectionStatus::Connected {\n          sink,\n          cancel: cancel.clone(),\n        });\n\n        if let Err(err) = actor.publish_pending_collabs().await {\n          sync_error!(\"failed to publish pending collabs: {}\", err);\n        }\n        tokio::spawn(Self::remote_receiver_task(\n          Arc::downgrade(actor),\n          stream,\n          cancel,\n        ));\n      },\n    }\n    Ok(())\n  }\n\n  async fn remote_receiver_task(\n    weak_actor: Weak<WorkspaceControllerActor>,\n    stream: SplitStream<WsConn>,\n    cancel: CancellationToken,\n  ) {\n    sync_debug!(\"websocket receiver task started\");\n    let reason = Self::remote_receiver_loop(weak_actor.clone(), stream, cancel.clone())\n      .await\n      .err();\n\n    if let Some(actor) = weak_actor.upgrade() {\n      if reason.is_some() {\n        sync_error!(\"failed to receive messages from server: {:?}\", reason);\n      } else {\n        sync_debug!(\"websocket receiver task ended\");\n      }\n      actor.set_connection_status(ConnectionStatus::Disconnected { reason });\n    }\n  }\n\n  async fn remote_receiver_loop(\n    weak_actor: Weak<WorkspaceControllerActor>,\n    mut stream: SplitStream<WsConn>,\n    cancel: CancellationToken,\n  ) -> Result<(), DisconnectedReason> {\n    let mut buf = BytesMut::new();\n    while let Some(res) = stream.next().await {\n      if cancel.is_cancelled() {\n        sync_trace!(\"remote receiver loop cancelled\");\n        return Err(DisconnectedReason::UserDisconnect(\"User disconnect\".into()));\n      }\n      let actor = match weak_actor.upgrade() {\n        Some(inner) => inner,\n        None => {\n          sync_trace!(\"remote receiver loop ended - actor dropped\");\n          break;\n        },\n      };\n      let msg = res?;\n      #[cfg(debug_assertions)]\n      {\n        if actor\n          .skip_realtime_message\n          .load(std::sync::atomic::Ordering::SeqCst)\n        {\n          sync_trace!(\"skipping realtime message\");\n          continue;\n        }\n      }\n      match msg {\n        Message::Binary(bytes) => {\n          sync_trace!(\"[WsMessage] received binary: len:{}\", bytes.len());\n          let msg = ServerMessage::from_bytes(&bytes)?;\n          actor.handle_receive(msg).await.map_err(|err| {\n            DisconnectedReason::CannotHandleReceiveMessage(err.to_string().into())\n          })?;\n        },\n        Message::Text(_) => {\n          sync_error!(\"text messages are not supported\");\n        },\n        Message::Ping(_) => { /* do nothing */ },\n        Message::Pong(_) => { /* do nothing */ },\n        Message::Frame(frame) => {\n          buf.extend_from_slice(frame.payload());\n          if frame.header().is_final {\n            let bytes = std::mem::take(&mut buf);\n            sync_trace!(\n              \"[WsMessage] received final frame, len:{}, total:{}\",\n              frame.len(),\n              bytes.len()\n            );\n            let msg = ServerMessage::from_bytes(&bytes)?;\n            actor.handle_receive(msg).await.map_err(|err| {\n              DisconnectedReason::CannotHandleReceiveMessage(err.to_string().into())\n            })?;\n          } else {\n            sync_trace!(\"[WsMessage] received frame: len:{}\", frame.len());\n          }\n        },\n        Message::Close(close) => {\n          match close {\n            None => sync_info!(\"received close request from server\"),\n            Some(frame) => sync_info!(\n              \"received close request from server: {} - {}\",\n              frame.code,\n              frame.reason\n            ),\n          }\n          return Err(DisconnectedReason::ServerForceClose);\n        },\n      }\n    }\n    sync_info!(\"websocket receiver loop ended\");\n    Ok(())\n  }\n\n  async fn handle_receive(&self, msg: ServerMessage) -> anyhow::Result<()> {\n    match msg {\n      ServerMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector,\n      } => {\n        sync_trace!(\n          \"received manifest message for {} (rid: {}), sv:{:?}\",\n          object_id,\n          last_message_id,\n          state_vector\n        );\n\n        let sv = if state_vector.is_empty() {\n          // If no inserts or other operations have ever been applied, the sv will be empty (i.e. []).\n          StateVector::default()\n        } else {\n          StateVector::decode_v1(&state_vector)?\n        };\n\n        let local_message_id = self.last_message_id();\n        if let Some(collab_ref) = self.get_collab(&object_id) {\n          let (msg, missing) = {\n            let lock = collab_ref.read().await;\n            let collab = lock.borrow();\n            let tx = collab.get_awareness().doc().transact();\n            let update = tx.encode_diff_v1(&sv); // encode state without pending updates\n            let msg = ClientMessage::Update {\n              object_id,\n              collab_type,\n              flags: UpdateFlags::Lib0v1,\n              update,\n            };\n            let missing =\n              Self::check_missing_updates(tx, object_id, collab_type, local_message_id)?;\n            if missing.is_some() {\n              collab.set_sync_state(SyncState::Syncing);\n            }\n            (msg, missing)\n          };\n          self.send_message(msg).await?;\n          if let Some(missing) = missing {\n            self.send_message(missing).await?;\n          }\n        }\n      },\n      ServerMessage::Update {\n        object_id,\n        flags,\n        last_message_id,\n        update,\n        collab_type,\n      } => {\n        // we don't need to decode update for every use case, but do so anyway to confirm\n        // that it isn't malformed\n        let update = match flags {\n          UpdateFlags::Lib0v1 if update != Update::EMPTY_V1 => Update::decode_v1(&update)?,\n          UpdateFlags::Lib0v2 if update != Update::EMPTY_V2 => Update::decode_v2(&update)?,\n          _ => return Ok(()),\n        };\n        self\n          .save_remote_update(object_id, collab_type, last_message_id, update)\n          .await?;\n      },\n      ServerMessage::AwarenessUpdate {\n        object_id,\n        awareness,\n        ..\n      } => {\n        // we don't need to decode update for every use case, but do so anyway to confirm\n        // that it isn't malformed\n        let update = AwarenessUpdate::decode_v1(&awareness)?;\n        self.save_awareness_update(object_id, update).await?;\n      },\n      ServerMessage::AccessChanges {\n        object_id, reason, ..\n      } => {\n        tracing::warn!(\n          \"received permission denied for {} - reason: {}\",\n          object_id,\n          reason\n        );\n        self.delete_collab(&object_id)?;\n      },\n      ServerMessage::Notification { notification } => {\n        sync_info!(\"received notification: {:?}\", notification);\n        self.send_notification(notification).await;\n      },\n    }\n    Ok(())\n  }\n\n  async fn send_notification(&self, notification: WorkspaceNotification) {\n    sync_trace!(\"Receive server notification: {:?}\", notification);\n    match &notification {\n      WorkspaceNotification::UserProfileChange { .. } => {},\n      WorkspaceNotification::ObjectAccessChanged { object_id, reason } => {\n        if matches!(reason, AccessChangedReason::ObjectDeleted) {\n          self.unbind(object_id).await;\n        }\n      },\n    }\n\n    if let Err(err) = self.notification_tx.send(notification) {\n      sync_error!(\"Failed to send server notification, error: {:?}\", err);\n    }\n  }\n\n  async fn publish_pending_collabs(&self) -> anyhow::Result<()> {\n    let last_message_id = self.last_message_id();\n    let mut pending: Vec<_> = self\n      .cache\n      .iter()\n      .map(|e| (*e.key(), e.value().collab_type))\n      .collect();\n\n    if pending.is_empty() {\n      return Ok(());\n    }\n\n    sync_info!(\n      \"[{}] Publishing pending collabs: {}\",\n      self.client_id(),\n      pending.len()\n    );\n    sync_debug!(\"[{}] has pending collabs: {:?}\", self.client_id(), pending);\n    // Sort by collab_type: Document > Database > Folder\n    pending.sort_by_key(|(_, collab_type)| match collab_type {\n      CollabType::Document => 0,\n      CollabType::Database => 1,\n      CollabType::Folder => 2,\n      _ => 3,\n    });\n\n    let mut inactive_collab = vec![];\n    for (object_id, collab_type) in pending {\n      if let Some(collab_ref) = self.get_collab(&object_id) {\n        let state_vector = collab_ref.read().await.borrow().transact().state_vector();\n        let manifest = ClientMessage::Manifest {\n          object_id,\n          collab_type,\n          last_message_id,\n          state_vector: state_vector.encode_v1(),\n        };\n        sync_trace!(\"publish pending collab: {:#?}\", manifest);\n        self.trigger(WorkspaceAction::Send(manifest, ActionSource::Local));\n      } else {\n        // remove collab that already dropped\n        self.remove_collab(&object_id);\n\n        // Collabs not in memory are considered inactive. We need to sync these when\n        // connection is established to handle changes made while offline.\n        sync_debug!(\n          \"[{}] pending collab {}/{} is inactive\",\n          self.client_id(),\n          object_id,\n          collab_type\n        );\n        inactive_collab.push((object_id, collab_type));\n      }\n    }\n\n    let cancel_token = CancellationToken::new();\n    let cancel_token_clone = cancel_token.clone();\n    let mut connect_status = self.status_tx.subscribe();\n    tokio::spawn(async move {\n      select! {\n        _ = cancel_token_clone.cancelled() => {\n          sync_info!(\"Deferred sync finished\");\n        }\n        _ = connect_status.changed() => {\n          if !matches!(*connect_status.borrow(), ConnectionStatus::Connected { .. }) {\n            cancel_token_clone.cancel();\n            sync_info!(\"Connection disconnect, cancel publishing inactive collabs\");\n          }\n        }\n      }\n    });\n\n    self.spawn_publish_inactive_collabs(inactive_collab, cancel_token);\n    Ok(())\n  }\n\n  /// Publish inactive collabs in the background.\n  pub fn spawn_publish_inactive_collabs(\n    &self,\n    collabs: Vec<(ObjectId, CollabType)>,\n    cancel_token: CancellationToken,\n  ) {\n    if collabs.is_empty() {\n      return;\n    }\n\n    sync_info!(\"Publishing inactive collabs: {}\", collabs.len());\n    let last_message_id = self.last_message_id();\n    let sender = self.mailbox.clone();\n    let db = self.db.clone();\n    let cache = Arc::downgrade(&self.cache);\n    tokio::spawn(async move {\n      for chunk in collabs.chunks(10) {\n        if cancel_token.is_cancelled() {\n          sync_info!(\"Deferred sync cancelled due to disconnection\");\n          break;\n        }\n\n        loop {\n          let num_of_unsynced_collab = match cache.upgrade() {\n            None => break,\n            Some(cache) => num_of_unsynced_collab(cache),\n          };\n          if num_of_unsynced_collab >= 10 {\n            sync_trace!(\n              \"Too many unsynced collabs ({}), delaying inactive collab sync\",\n              num_of_unsynced_collab\n            );\n            tokio::time::sleep(Duration::from_secs(5)).await;\n            if cancel_token.is_cancelled() {\n              sync_info!(\"Deferred sync cancelled during wait\");\n              return;\n            }\n          } else {\n            break;\n          }\n        }\n\n        let object_ids: Vec<_> = chunk.iter().map(|(object_id, _)| object_id).collect();\n        match db.batch_get_state_vector(&object_ids) {\n          Ok(vectors) => {\n            if let Err(err) = publish_inactive_collab(last_message_id, &sender, chunk, vectors) {\n              sync_error!(\"Failed to publish inactive collab: {}\", err);\n              return;\n            }\n          },\n          Err(err) => {\n            sync_error!(\"Failed to get state vectors for batch: {}\", err);\n          },\n        }\n        tokio::time::sleep(Duration::from_secs(10)).await;\n      }\n\n      cancel_token.cancel();\n    });\n  }\n\n  fn check_missing_updates(\n    tx: Transaction,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    local_message_id: Rid,\n  ) -> anyhow::Result<Option<ClientMessage>> {\n    if tx.store().pending_update().is_some() || tx.store().pending_ds().is_some() {\n      let sv = tx.state_vector();\n      sync_trace!(\"collab {} detected missing updates: {:?}\", object_id, sv);\n      let reply = ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id: local_message_id,\n        state_vector: sv.encode_v1(),\n      };\n      Ok(Some(reply))\n    } else {\n      Ok(None)\n    }\n  }\n\n  /// Applies or persists remote updates for collaborative objects.\n  ///\n  /// # Arguments\n  /// * `object_id` - The identifier of the collaborative object\n  /// * `collab_type` - The type of collaboration\n  /// * `rid` - The message identifier for this update\n  /// * `update` - The update data to apply\n  ///\n  /// # Behavior\n  /// - For active collabs (in memory): Directly applies the update and checks for missing updates\n  /// - For inactive collabs: Encodes and persists the update to the database\n  ///\n  /// Sets the sync state to SyncFinished when successful or triggers manifest publication\n  /// if missing updates are detected.\n  async fn save_remote_update(\n    &self,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    rid: Rid,\n    update: Update,\n  ) -> anyhow::Result<()> {\n    if let Some(collab_ref) = self.get_collab(&object_id) {\n      sync_debug!(\n        \"[{}] applying remote update for collab {}/{} active: {:#?}\",\n        self.db.client_id(),\n        object_id,\n        collab_type,\n        update\n      );\n\n      let mut lock = collab_ref.write().await;\n      let collab = (*lock).borrow_mut();\n      let doc = collab.get_awareness().doc();\n      let mut tx = doc.transact_mut_with(rid.into_bytes().as_ref());\n      tx.apply_update(update)?;\n\n      // We try to prune missing updates. If there were any, return true.\n      // Server, when requested, will resend \"continuous\" missing updates\n      // (with no holes inside) so on the second resend we either get all\n      // missing updates or none at all.\n      let missing = tx.prune_pending();\n      drop(tx);\n\n      // If there are no missing updates, we can set the sync state to SyncFinished\n      // If there are missing updates, we will send a manifest to request them\n      match missing {\n        None => {\n          collab.set_sync_state(SyncState::SyncFinished);\n        },\n        Some(update) => {\n          sync_debug!(\n            \"[{}] found missing update:{:#?} for {} - sending manifest\",\n            self.db.client_id(),\n            update,\n            object_id\n          );\n          self.publish_manifest(object_id, collab, collab_type);\n        },\n      }\n    } else {\n      sync_trace!(\n        \"storing remote update for collab {}/{} inactive: {:#?}\",\n        object_id,\n        collab_type,\n        update\n      );\n      let bytes = update.encode_v1();\n      self\n        .persist_update(\n          object_id,\n          collab_type,\n          Some(rid),\n          bytes,\n          ActionSource::Remote(rid),\n        )\n        .await?;\n    }\n    Ok(())\n  }\n\n  /// Saves the provided update to the persistent database and checks if there are any missing\n  /// updates in the collaboration sequence. If gaps are detected in the update history, it\n  /// automatically triggers a manifest message to request the missing updates.\n  ///\n  /// It is primarily used when receiving updates for collaborative objects that\n  /// aren't currently active in memory. Active objects handle their updates through the\n  /// in-memory collaboration object directly.\n  async fn persist_update(\n    &self,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    last_message_id: Option<Rid>,\n    update_bytes: Vec<u8>,\n    action_source: ActionSource,\n  ) -> anyhow::Result<()> {\n    if matches!(action_source, ActionSource::Remote(_)) {\n      let _ = self.changed_collab_sender.send(ChangedCollab {\n        id: object_id,\n        collab_type,\n      });\n    }\n\n    let missing = self\n      .db\n      .save_update(&object_id, last_message_id, &update_bytes, action_source)?;\n    if let Some(state_vector) = missing {\n      let msg = ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id: last_message_id.unwrap_or_default(),\n        state_vector: state_vector.encode_v1(),\n      };\n      // we received that manifest from the local client\n      self.trigger(WorkspaceAction::Send(msg, ActionSource::Local));\n    }\n    Ok(())\n  }\n\n  async fn save_awareness_update(\n    &self,\n    object_id: ObjectId,\n    update: AwarenessUpdate,\n  ) -> anyhow::Result<()> {\n    if let Some(collab_ref) = self.get_collab(&object_id) {\n      let mut lock = collab_ref.write().await;\n      let collab = (*lock).borrow_mut();\n\n      sync_trace!(\n        \"applying awareness update for active collab {}: {:#?}\",\n        object_id,\n        update\n      );\n      collab\n        .get_awareness()\n        .apply_update_with(update, Self::REMOTE_ORIGIN)?;\n    }\n    Ok(())\n  }\n\n  async fn handle_disconnect(&self) -> anyhow::Result<()> {\n    let previous_status = self\n      .status_tx\n      .send_replace(ConnectionStatus::Disconnected { reason: None });\n    match previous_status {\n      ConnectionStatus::Connected { sink, cancel } => {\n        cancel.cancel();\n        {\n          let mut sink = sink.lock().await;\n          sink.flush().await?;\n          sink.close().await?;\n        }\n        Ok(())\n      },\n      ConnectionStatus::Connecting { cancel } => {\n        sync_trace!(\"[{}] cancelling connection\", self.db.client_id());\n        cancel.cancel();\n        Ok(())\n      },\n      ConnectionStatus::Disconnected { .. } => Ok(()),\n      ConnectionStatus::StartReconnect => Ok(()),\n    }\n  }\n\n  fn publish_manifest(&self, object_id: ObjectId, collab: &Collab, collab_type: CollabType) {\n    let last_message_id = self.last_message_id();\n    let awareness = collab.get_awareness();\n    let doc = awareness.doc();\n    let state_vector = doc.transact().state_vector();\n    sync_debug!(\n      \"publishing manifest for {} (last msg id: {}): {:?}\",\n      object_id,\n      last_message_id,\n      state_vector\n    );\n    let msg = ClientMessage::Manifest {\n      object_id,\n      collab_type,\n      last_message_id,\n      state_vector: state_vector.encode_v1(),\n    };\n    // we received that update from the local client\n    self.trigger(WorkspaceAction::Send(msg, ActionSource::Local));\n  }\n\n  fn publish_update(\n    &self,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    source: ActionSource,\n    update_v1: Vec<u8>,\n  ) {\n    let msg = ClientMessage::Update {\n      object_id,\n      collab_type,\n      flags: UpdateFlags::Lib0v1,\n      update: update_v1,\n    };\n    // we received that update from the local client\n    self.trigger(WorkspaceAction::Send(msg, source));\n  }\n\n  fn publish_awareness(\n    &self,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    update: AwarenessUpdate,\n  ) {\n    tracing::trace!(\n      \"[{}] publishing awareness update for {}: {:#?}\",\n      self.db.client_id(),\n      object_id,\n      update\n    );\n    let awareness = update.encode_v1();\n    let msg = ClientMessage::AwarenessUpdate {\n      object_id,\n      collab_type,\n      awareness,\n    };\n    self.trigger(WorkspaceAction::Send(msg, ActionSource::Local));\n  }\n\n  fn ws_sink(&self) -> Option<Arc<Mutex<SplitSink<WsConn, Message>>>> {\n    match &*self.status_rx.borrow() {\n      ConnectionStatus::Connected { sink, .. } => Some(sink.clone()),\n      _ => None,\n    }\n  }\n\n  async fn establish_connection(\n    options: &Options,\n    client_id: ClientID,\n    last_message_id: &Rid,\n    cancel: CancellationToken,\n    access_token: String,\n  ) -> Result<Option<WsConn>, AppResponseError> {\n    let mut url = format!(\n      \"{}/{}?clientId={}&deviceId={}\",\n      options.url, options.workspace_id, client_id, options.device_id\n    );\n    sync_info!(\"establishing WebSocket connection to: {}\", url);\n    // don't include auth token in the log message (or maybe it doesn't matter?)\n    write!(url, \"&token={}\", access_token).unwrap();\n    if options.sync_eagerly {\n      write!(url, \"&lastMessageId={}\", last_message_id).unwrap();\n    }\n    let req = url.into_client_request()?;\n    let config = WebSocketConfig {\n      max_frame_size: None,\n      ..WebSocketConfig::default()\n    };\n    let fut = connect_async_with_config(req, Some(config), false);\n    tokio::select! {\n      res = fut => {\n        match res {\n          Ok((stream, _resp)) => {\n            sync_info!(\"establishing WebSocket successfully to {}\", options.workspace_id);\n            Ok(Some(stream))\n          },\n          Err(err) => {\n            sync_error!(\"establishing WebSocket failed to {}\", options.workspace_id);\n            Err(AppError::from(err).into())\n          }\n        }\n      }\n      _ = cancel.cancelled() => {\n        sync_info!(\"establishing connection cancelled for {}\", options.workspace_id);\n        Ok(None)\n      }\n    }\n  }\n}\n\n#[derive(Debug)]\npub(super) enum WorkspaceAction {\n  Connect {\n    ack: tokio::sync::oneshot::Sender<Result<(), AppResponseError>>,\n    access_token: String,\n  },\n  Disconnect(tokio::sync::oneshot::Sender<anyhow::Result<()>>),\n  Send(ClientMessage, ActionSource),\n}\n\n#[derive(Debug, Copy, Clone)]\npub(super) enum ActionSource {\n  Local,\n  Remote(Rid),\n}\n\nimpl Display for ActionSource {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ActionSource::Local => f.write_str(\"local\"),\n      ActionSource::Remote(_) => f.write_str(\"remote\"),\n    }\n  }\n}\n\nimpl From<ActionSource> for Option<Rid> {\n  fn from(value: ActionSource) -> Self {\n    match value {\n      ActionSource::Local => None,\n      ActionSource::Remote(rid) => Some(rid),\n    }\n  }\n}\n\nimpl From<Option<Rid>> for ActionSource {\n  fn from(value: Option<Rid>) -> Self {\n    match value {\n      None => ActionSource::Local,\n      Some(rid) => ActionSource::Remote(rid),\n    }\n  }\n}\n\npub(super) type WsConn = tokio_tungstenite::WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;\n\npub(super) type WorkspaceControllerMailbox = tokio::sync::mpsc::UnboundedSender<WorkspaceAction>;\n\n/// Contains cached information about a collab document\n#[derive(Clone)]\nstruct CachedCollab {\n  collab_ref: WeakCollabRef,\n  collab_type: CollabType,\n}\n\nimpl CachedCollab {\n  fn new(collab_ref: WeakCollabRef, collab_type: CollabType) -> Self {\n    Self {\n      collab_ref,\n      collab_type,\n    }\n  }\n\n  fn upgrade(&self) -> Option<CollabRef> {\n    self.collab_ref.upgrade()\n  }\n}\n\n#[derive(Debug, Clone)]\npub struct ChangedCollab {\n  pub id: ObjectId,\n  pub collab_type: CollabType,\n}\n\nimpl PartialEq for ChangedCollab {\n  fn eq(&self, other: &Self) -> bool {\n    self.id == other.id\n  }\n}\nimpl Eq for ChangedCollab {}\n\nimpl Hash for ChangedCollab {\n  fn hash<H: Hasher>(&self, state: &mut H) {\n    self.id.hash(state);\n  }\n}\nimpl std::borrow::Borrow<ObjectId> for ChangedCollab {\n  fn borrow(&self) -> &ObjectId {\n    &self.id\n  }\n}\n\nconst OBSERVER_KEY: &str = \"af\";\n\nfn unobserve_awareness(awareness: &Awareness) {\n  awareness.unobserve_change(OBSERVER_KEY);\n}\n\n#[inline]\nfn observe_awareness(\n  actor: &Arc<WorkspaceControllerActor>,\n  collab_type: CollabType,\n  object_id: ObjectId,\n  client_id: ClientID,\n  awareness: &Awareness,\n) {\n  let weak_inner = Arc::downgrade(actor);\n  awareness.on_change_with(OBSERVER_KEY, move |awareness, e, origin| {\n    if let Some(inner) = weak_inner.upgrade() {\n      if origin.map(|o| o.as_ref()) != Some(WorkspaceControllerActor::REMOTE_ORIGIN.as_bytes()) {\n        match awareness.update_with_clients(e.all_changes()) {\n          Ok(update) => inner.publish_awareness(object_id, collab_type, update),\n          Err(err) => error!(\n            \"[{}] failed to prepare awareness update for {}: {}\",\n            client_id, object_id, err\n          ),\n        }\n      }\n    }\n  });\n}\n\nfn unobserve_update(object_id: &ObjectId, awareness: &Awareness) -> anyhow::Result<()> {\n  awareness.doc().unobserve_update_v1(OBSERVER_KEY)?;\n  sync_trace!(\"unobserve update for {}\", object_id);\n  Ok(())\n}\n\n#[inline]\nfn observe_update(\n  collab_type: CollabType,\n  object_id: ObjectId,\n  sync_state: Arc<State>,\n  last_message_id: Arc<ArcSwap<Rid>>,\n  weak_inner: Weak<WorkspaceControllerActor>,\n  client_id: ClientID,\n  awareness: &Awareness,\n) -> anyhow::Result<()> {\n  awareness\n    .doc()\n    .observe_update_v1_with(OBSERVER_KEY, move |tx, e| {\n      if let Some(inner) = weak_inner.upgrade() {\n        let rid: ActionSource = tx\n          .origin()\n          .and_then(|origin| Rid::from_bytes(origin.as_ref()).ok())\n          .into();\n        sync_trace!(\n          \"[{}] emit collab update {:?} {:#?} \",\n          client_id,\n          rid,\n          Update::decode_v1(&e.update).unwrap()\n        );\n        if let ActionSource::Remote(rid) = rid {\n          sync_trace!(\"[{}] {} received collab from remote\", client_id, object_id);\n          last_message_id.rcu(|old| {\n            if rid > **old {\n              Arc::new(rid)\n            } else {\n              old.clone()\n            }\n          });\n        } else {\n          sync_state.set_sync_state(SyncState::Syncing);\n        }\n        inner.publish_update(object_id, collab_type, rid, e.update.clone());\n      }\n    })?;\n  Ok(())\n}\n\nfn publish_inactive_collab(\n  last_message_id: Rid,\n  sender: &UnboundedSender<WorkspaceAction>,\n  chunk: &[(ObjectId, CollabType)],\n  vectors: Vec<(ObjectId, StateVector)>,\n) -> anyhow::Result<()> {\n  let mut collab_types = chunk\n    .iter()\n    .map(|(object_id, collab_type)| (object_id, *collab_type))\n    .collect::<HashMap<_, _>>();\n\n  for (object_id, state_vector) in vectors {\n    if let Some(collab_type) = collab_types.remove(&object_id) {\n      let manifest = ClientMessage::Manifest {\n        object_id,\n        collab_type,\n        last_message_id,\n        state_vector: state_vector.encode_v1(),\n      };\n\n      sender.send(WorkspaceAction::Send(manifest, ActionSource::Local))?;\n    }\n  }\n  Ok(())\n}\n\nfn num_of_unsynced_collab(cache: Arc<DashMap<ObjectId, CachedCollab>>) -> usize {\n  cache\n    .iter()\n    .filter(|entry| {\n      if let Some(collab) = entry.value().upgrade() {\n        collab\n          .try_read()\n          .map(|guard| guard.borrow().get_state().sync_state() != SyncState::SyncFinished)\n          .unwrap_or(true)\n      } else {\n        false\n      }\n    })\n    .count()\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/compactor.rs",
    "content": "use crate::sync_error;\nuse crate::v2::actor::{ActionSource, WorkspaceAction};\nuse crate::v2::ObjectId;\nuse appflowy_proto::{ClientMessage, UpdateFlags};\nuse client_api_entity::CollabType;\nuse smallvec::{smallvec, SmallVec};\nuse tokio::sync::mpsc::UnboundedReceiver;\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::Encode;\n\npub(super) struct ChannelReceiverCompactor {\n  receiver: UnboundedReceiver<WorkspaceAction>,\n  peeked: Option<WorkspaceAction>,\n}\n\nimpl ChannelReceiverCompactor {\n  pub(super) fn new(receiver: UnboundedReceiver<WorkspaceAction>) -> Self {\n    Self {\n      receiver,\n      peeked: None,\n    }\n  }\n\n  pub async fn recv(&mut self) -> Option<WorkspaceAction> {\n    let latest = match self.peeked.take() {\n      Some(action) => action,\n      None => self.receiver.recv().await?,\n    };\n    match latest {\n      WorkspaceAction::Send(\n        ClientMessage::Update {\n          object_id,\n          collab_type,\n          flags,\n          update,\n        },\n        ActionSource::Local,\n      ) => self\n        .try_prefetch(object_id, collab_type, flags, update)\n        .ok(),\n      other => Some(other),\n    }\n  }\n\n  /// Given initial [ClientMessage::Update] payload, try to prefetch (without blocking or awaiting) as\n  /// many bending messages from the collab stream as possible.\n  ///\n  /// This is so that we can even the difference between frequent updates coming from the user with\n  /// slower responding server by merging a lot of small updates together into a bigger one.\n  ///\n  /// Returns a compacted update message.\n  fn try_prefetch(\n    &mut self,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    flags: UpdateFlags,\n    update: Vec<u8>,\n  ) -> anyhow::Result<WorkspaceAction> {\n    const SIZE_THRESHOLD: usize = 64 * 1024;\n    let mut size_hint = update.len();\n    let mut updates: SmallVec<[(UpdateFlags, Vec<u8>); 1]> = smallvec![(flags, update)];\n    while let Ok(next) = self.receiver.try_recv() {\n      match next {\n        WorkspaceAction::Send(\n          ClientMessage::Update {\n            object_id: next_object_id,\n            collab_type: next_collab_type,\n            flags,\n            update,\n          },\n          ActionSource::Local,\n        ) if next_object_id == object_id && next_collab_type == collab_type => {\n          size_hint += update.len();\n          // we stack updates together until we reach a non-update message\n          updates.push((flags, update));\n\n          if size_hint >= SIZE_THRESHOLD {\n            break; // potential size of the update may be over threshold, stop here and send what we have\n          }\n        },\n        WorkspaceAction::Send(\n          ClientMessage::Update {\n            object_id: next_object_id,\n            collab_type: next_collab_type,\n            flags,\n            update,\n          },\n          ActionSource::Local,\n        ) if next_object_id == object_id && next_collab_type != collab_type => {\n          // It's impossible the same object_id to have different collab_types. If this happens,\n          // it must be a bug in the client code.\n          sync_error!(\n            \"Attempted to compact updates with same object_id ({}) but different collab_types: {:?} vs {:?}\",\n            object_id,\n            collab_type,\n            next_collab_type\n          );\n          // Cannot compact updates, so we just prepend this message and break\n          self.peeked = Some(WorkspaceAction::Send(\n            ClientMessage::Update {\n              object_id: next_object_id,\n              collab_type: next_collab_type,\n              flags,\n              update,\n            },\n            ActionSource::Local,\n          ));\n          break;\n        },\n        other => {\n          // other type of message, we cannot compact updates anymore,\n          // so we just prepend the update message and then add new one and send them\n          // all together\n          self.peeked = Some(other);\n          break;\n        },\n      }\n    }\n    let update_count = updates.len();\n    if update_count == 1 {\n      let (flags, update) = std::mem::take(&mut updates[0]);\n      Ok(WorkspaceAction::Send(\n        ClientMessage::Update {\n          object_id,\n          collab_type,\n          flags,\n          update,\n        },\n        ActionSource::Local,\n      ))\n    } else {\n      let updates = updates.into_iter().flat_map(|(flags, bytes)| match flags {\n        UpdateFlags::Lib0v1 => yrs::Update::decode_v1(&bytes).ok(),\n        UpdateFlags::Lib0v2 => yrs::Update::decode_v2(&bytes).ok(),\n      });\n      let update = yrs::Update::merge_updates(updates).encode_v1();\n      tracing::debug!(\n        \"compacted {} updates ({} bytes) into one ({} bytes)\",\n        update_count,\n        size_hint,\n        update.len()\n      );\n      Ok(WorkspaceAction::Send(\n        ClientMessage::Update {\n          object_id,\n          collab_type,\n          flags: UpdateFlags::Lib0v1,\n          update,\n        },\n        ActionSource::Local,\n      ))\n    }\n  }\n}\n\n#[cfg(test)]\nmod test {\n  use crate::entity::CollabType;\n  use crate::v2::actor::{ActionSource, WorkspaceAction};\n  use crate::v2::compactor::ChannelReceiverCompactor;\n  use appflowy_proto::{ClientMessage, UpdateFlags};\n  use collab::core::collab::default_client_id;\n  use collab::preclude::Collab;\n  use std::sync::atomic::AtomicUsize;\n  use std::sync::Arc;\n  use tokio::sync::mpsc::unbounded_channel;\n  use uuid::Uuid;\n  use yrs::updates::decoder::Decode;\n  use yrs::Update;\n\n  #[tokio::test]\n  async fn update_compaction() {\n    let oid = Uuid::new_v4();\n    let mut c1 = Collab::new(1, oid.to_string(), \"device-id\", default_client_id());\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n    let counter = Arc::new(AtomicUsize::new(0));\n    let counter_clone = counter.clone();\n    c1.doc()\n      .observe_update_v1_with(\"test\", move |_, e| {\n        counter_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);\n        let action = WorkspaceAction::Send(\n          ClientMessage::Update {\n            object_id: oid,\n            collab_type: CollabType::Unknown,\n            flags: UpdateFlags::Lib0v1,\n            update: e.update.clone(),\n          },\n          ActionSource::Local,\n        );\n        let _ = tx.send(action);\n      })\n      .unwrap();\n    const UPDATE_COUNT: usize = 5;\n    for i in 0..UPDATE_COUNT {\n      c1.insert(&format!(\"key-{i}\"), format!(\"value-{i}\"));\n    }\n    assert_eq!(\n      counter.load(std::sync::atomic::Ordering::SeqCst),\n      UPDATE_COUNT\n    );\n    let Some(WorkspaceAction::Send(\n      ClientMessage::Update { flags, update, .. },\n      ActionSource::Local,\n    )) = rx.recv().await\n    else {\n      panic!(\"expected Some(ClientMessage::Update)\");\n    };\n\n    // we produced UPDATE_COUNT updates on C1, but only took 1 update on C2\n    // this should be fine as compactor should deal with compacing pending updates\n    let mut c2 = Collab::new(2, oid.to_string(), \"device-id\", default_client_id());\n    let update = match flags {\n      UpdateFlags::Lib0v1 => Update::decode_v1(&update).unwrap(),\n      UpdateFlags::Lib0v2 => Update::decode_v2(&update).unwrap(),\n    };\n    c2.apply_update(update).unwrap();\n\n    let state1 = c1.to_json_value();\n    let state2 = c2.to_json_value();\n    assert_eq!(state1, state2);\n  }\n\n  #[tokio::test]\n  async fn no_compaction_for_different_object_ids() {\n    let oid1 = Uuid::new_v4();\n    let oid2 = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    // Send updates for two different objects\n    let update1 = vec![1, 2, 3];\n    let update2 = vec![4, 5, 6];\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid1,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update1.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid2,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update2.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // First message should only contain the first update (no compaction)\n    let first_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id, update, ..\n      },\n      _,\n    ) = first_msg\n    {\n      assert_eq!(object_id, oid1);\n      assert_eq!(update, update1);\n    } else {\n      panic!(\"Expected update message\");\n    }\n\n    // Second message should be available as peeked\n    let second_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id, update, ..\n      },\n      _,\n    ) = second_msg\n    {\n      assert_eq!(object_id, oid2);\n      assert_eq!(update, update2);\n    } else {\n      panic!(\"Expected update message\");\n    }\n  }\n\n  #[tokio::test]\n  async fn mixed_message_types_break_compaction() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    // Send an update, then a non-update message, then another update\n    let update1 = vec![1, 2, 3];\n    let update2 = vec![4, 5, 6];\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update1.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    // Non-update message that should break compaction\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::AwarenessUpdate {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        awareness: vec![1, 2, 3],\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update2.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // First message should only contain the first update\n    let first_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(ClientMessage::Update { update, .. }, _) = first_msg {\n      assert_eq!(update, update1);\n    } else {\n      panic!(\"Expected update message\");\n    }\n\n    // Second message should be the awareness update\n    let second_msg = rx.recv().await.unwrap();\n    assert!(matches!(\n      second_msg,\n      WorkspaceAction::Send(ClientMessage::AwarenessUpdate { .. }, _)\n    ));\n\n    // Third message should be the second update\n    let third_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(ClientMessage::Update { update, .. }, _) = third_msg {\n      assert_eq!(update, update2);\n    } else {\n      panic!(\"Expected update message\");\n    }\n  }\n\n  #[tokio::test]\n  async fn single_update_no_compaction() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    let update = vec![1, 2, 3];\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Document,\n        flags: UpdateFlags::Lib0v2,\n        update: update.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    let msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: recv_oid,\n        collab_type,\n        flags,\n        update: recv_update,\n      },\n      source,\n    ) = msg\n    {\n      assert_eq!(recv_oid, oid);\n      assert_eq!(collab_type, CollabType::Document);\n      assert_eq!(flags, UpdateFlags::Lib0v2);\n      assert_eq!(recv_update, update);\n      assert!(matches!(source, ActionSource::Local));\n    } else {\n      panic!(\"Expected update message\");\n    }\n  }\n\n  #[tokio::test]\n  async fn remote_updates_not_compacted() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    let update = vec![1, 2, 3];\n    let rid = appflowy_proto::Rid {\n      timestamp: 123456789,\n      seq_no: 1,\n    };\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update.clone(),\n      },\n      ActionSource::Remote(rid),\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // Remote updates should pass through without compaction attempt\n    let msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        update: recv_update,\n        ..\n      },\n      ActionSource::Remote(_),\n    ) = msg\n    {\n      assert_eq!(recv_update, update);\n    } else {\n      panic!(\"Expected remote update message\");\n    }\n  }\n\n  #[tokio::test]\n  async fn size_threshold_limits_compaction() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    // Create large updates that exceed the 64KB threshold\n    const LARGE_UPDATE_SIZE: usize = 20 * 1024; // 20KB each\n    const UPDATE_COUNT: usize = 5; // Total: 100KB > 64KB threshold\n\n    for i in 0..UPDATE_COUNT {\n      let large_update = vec![i as u8; LARGE_UPDATE_SIZE];\n      tx.send(WorkspaceAction::Send(\n        ClientMessage::Update {\n          object_id: oid,\n          collab_type: CollabType::Unknown,\n          flags: UpdateFlags::Lib0v1,\n          update: large_update,\n        },\n        ActionSource::Local,\n      ))\n      .unwrap();\n    }\n\n    drop(tx);\n\n    let msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(ClientMessage::Update { .. }, _) = msg {\n      // The compactor should have stopped before processing all updates due to size threshold\n      // We can't easily verify the exact behavior without access to internal state,\n      // but we can verify it didn't crash and produced a valid message\n    } else {\n      panic!(\"Expected update message\");\n    }\n  }\n\n  #[tokio::test]\n  async fn mixed_update_flags_compaction() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    // Create valid minimal YRS updates for testing\n    // These are minimal valid YRS v1 updates that can be decoded\n    let update1 = vec![0, 0]; // Empty YRS update v1\n    let update2 = vec![0, 0]; // Empty YRS update v1\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update1,\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v2,\n        update: update2,\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    let msg = rx.recv().await;\n    if let Some(WorkspaceAction::Send(ClientMessage::Update { flags, .. }, ActionSource::Local)) =\n      msg\n    {\n      // When compacting mixed flags, the result should always be Lib0v1\n      assert_eq!(flags, UpdateFlags::Lib0v1);\n    } else {\n      // If the mock updates cause errors during YRS processing, that's acceptable\n      // for this test since we're testing the error handling path\n      println!(\"Compaction failed (expected with mock updates)\");\n    }\n  }\n\n  #[tokio::test]\n  async fn peeked_message_handling() {\n    let oid1 = Uuid::new_v4();\n    let oid2 = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    let update1 = vec![1, 2, 3];\n    let update2 = vec![4, 5, 6];\n\n    // Send updates for same object, then different object\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid1,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update1.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid1,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update1.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    // This should break compaction and be peeked\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid2,\n        collab_type: CollabType::Unknown,\n        flags: UpdateFlags::Lib0v1,\n        update: update2.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // First recv should get compacted updates for oid1\n    let first_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(ClientMessage::Update { object_id, .. }, _) = first_msg {\n      assert_eq!(object_id, oid1);\n    } else {\n      panic!(\"Expected update message for oid1\");\n    }\n\n    // Second recv should get the peeked update for oid2\n    let second_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id, update, ..\n      },\n      _,\n    ) = second_msg\n    {\n      assert_eq!(object_id, oid2);\n      assert_eq!(update, update2);\n    } else {\n      panic!(\"Expected update message for oid2\");\n    }\n\n    // No more messages\n    assert!(rx.recv().await.is_none());\n  }\n\n  #[tokio::test]\n  async fn different_collab_types_not_compacted() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    let update1 = vec![1, 2, 3];\n    let update2 = vec![4, 5, 6];\n\n    // Send updates for same object but different collab types\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Document,\n        flags: UpdateFlags::Lib0v1,\n        update: update1.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Database,\n        flags: UpdateFlags::Lib0v1,\n        update: update2.clone(),\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // Should NOT compact since collab_types are different\n    // First message should only contain the first update\n    let first_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id,\n        collab_type,\n        update,\n        ..\n      },\n      _,\n    ) = first_msg\n    {\n      assert_eq!(object_id, oid);\n      assert_eq!(collab_type, CollabType::Document);\n      assert_eq!(update, update1);\n    } else {\n      panic!(\"Expected update message\");\n    }\n\n    // Second message should be the second update (peeked)\n    let second_msg = rx.recv().await.unwrap();\n    if let WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id,\n        collab_type,\n        update,\n        ..\n      },\n      _,\n    ) = second_msg\n    {\n      assert_eq!(object_id, oid);\n      assert_eq!(collab_type, CollabType::Database);\n      assert_eq!(update, update2);\n    } else {\n      panic!(\"Expected update message\");\n    }\n\n    // No more messages\n    assert!(rx.recv().await.is_none());\n  }\n\n  #[tokio::test]\n  async fn same_collab_type_and_object_compacted() {\n    let oid = Uuid::new_v4();\n    let (tx, rx) = unbounded_channel();\n    let mut rx = ChannelReceiverCompactor::new(rx);\n\n    let update1 = vec![0, 0]; // Empty YRS update v1\n    let update2 = vec![0, 0]; // Empty YRS update v1\n\n    // Send updates for same object and same collab type\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Document,\n        flags: UpdateFlags::Lib0v1,\n        update: update1,\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    tx.send(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id: oid,\n        collab_type: CollabType::Document,\n        flags: UpdateFlags::Lib0v1,\n        update: update2,\n      },\n      ActionSource::Local,\n    ))\n    .unwrap();\n\n    drop(tx);\n\n    // Should compact since both object_id and collab_type are the same\n    let msg = rx.recv().await;\n    if let Some(WorkspaceAction::Send(\n      ClientMessage::Update {\n        object_id,\n        collab_type,\n        flags,\n        ..\n      },\n      ActionSource::Local,\n    )) = msg\n    {\n      assert_eq!(object_id, oid);\n      assert_eq!(collab_type, CollabType::Document);\n      assert_eq!(flags, UpdateFlags::Lib0v1); // Should always be v1 after compaction\n    } else {\n      // If YRS processing fails with empty updates, that's fine for this test\n      println!(\"Compaction may have failed with empty YRS updates (acceptable)\");\n    }\n\n    // Should be no more messages since they were compacted\n    assert!(rx.recv().await.is_none());\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/conn_retry.rs",
    "content": "use crate::v2::actor::WorkspaceControllerActor;\nuse crate::v2::controller::{ConnectionStatus, DisconnectedReason};\nuse crate::{sync_error, sync_info, sync_trace};\nuse arc_swap::ArcSwap;\nuse async_trait::async_trait;\nuse shared_entity::response::AppResponseError;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\nuse tokio::time::sleep;\n\n#[async_trait]\npub trait ReconnectTarget: Send + Sync {\n  fn status_channel(&self) -> &tokio::sync::watch::Receiver<ConnectionStatus>;\n\n  async fn attempt_connect(self: Arc<Self>, token: String) -> Result<(), AppResponseError>;\n\n  fn set_disconnected(&self, reason: DisconnectedReason);\n}\n\n#[derive(Debug, Clone)]\npub struct RetryConfig {\n  /// Initial delay before first reconnect attempt.\n  pub initial_delay: Duration,\n  /// Maximum delay between reconnect attempts.\n  pub max_delay: Duration,\n  /// Maximum number of reconnect attempts before giving up.\n  pub max_attempts: u32,\n}\n\nimpl Default for RetryConfig {\n  fn default() -> Self {\n    Self {\n      initial_delay: Duration::from_secs(5),\n      max_delay: Duration::from_secs(120),\n      max_attempts: 5,\n    }\n  }\n}\n\n#[derive(Clone)]\npub(crate) struct ReconnectionManager {\n  config: RetryConfig,\n  in_progress: Arc<AtomicBool>,\n  target: Weak<dyn ReconnectTarget + Send + Sync>,\n  access_token: Arc<ArcSwap<String>>,\n}\n\nimpl ReconnectionManager {\n  /// Creates a new manager for the given target with custom config.\n  pub fn with_config_for_target(\n    target: Arc<dyn ReconnectTarget + Send + Sync>,\n    config: RetryConfig,\n  ) -> Self {\n    Self {\n      config,\n      in_progress: Arc::new(AtomicBool::new(false)),\n      target: Arc::downgrade(&target),\n      access_token: Default::default(),\n    }\n  }\n\n  /// Construct using the default retry config.\n  pub fn new_for_target(target: Arc<dyn ReconnectTarget + Send + Sync>) -> Self {\n    Self::with_config_for_target(target, RetryConfig::default())\n  }\n\n  /// Convenience ctor for production: pass the concrete actor.\n  pub fn new(actor: Arc<WorkspaceControllerActor>) -> Self {\n    // Coerce to trait object automatically.\n    let dyn_target: Arc<dyn ReconnectTarget + Send + Sync> = actor.clone();\n    Self::new_for_target(dyn_target)\n  }\n\n  pub fn set_access_token(&self, token: String) {\n    self.access_token.store(Arc::new(token));\n  }\n\n  pub fn trigger_reconnect(&self, reason: &str) {\n    sync_info!(?reason, \"trigger_reconnect\");\n\n    let token = self.access_token.load().as_ref().clone();\n    if token.is_empty() {\n      sync_info!(\"no access token → abort reconnect\");\n      return;\n    }\n\n    if self.in_progress.swap(true, Ordering::SeqCst) {\n      sync_trace!(\"reconnect already in progress, skip retry\");\n      return;\n    }\n\n    let manager = self.clone();\n    let weak_target = self.target.clone();\n    tokio::spawn(async move {\n      if let Some(target) = weak_target.upgrade() {\n        if manager.retry_with_exponential_backoff(target, token).await {\n          sync_info!(\"reconnect succeeded\");\n        } else {\n          sync_error!(\"reconnect failed\");\n        }\n      }\n      manager.in_progress.store(false, Ordering::SeqCst);\n    });\n  }\n\n  async fn retry_with_exponential_backoff(\n    &self,\n    target: Arc<dyn ReconnectTarget + Send + Sync>,\n    token: String,\n  ) -> bool {\n    let mut delay = self.config.initial_delay;\n    for attempt in 1..=self.config.max_attempts {\n      sync_trace!(attempt, ?delay, \"waiting before reconnect\");\n      sleep(delay).await;\n\n      // Stop if we're already (re)connecting or non-retriable\n      match &*target.status_channel().borrow() {\n        ConnectionStatus::Connected { .. } | ConnectionStatus::Connecting { .. } => {\n          sync_trace!(\"already connected/connecting; stopping retries\");\n          return false;\n        },\n        ConnectionStatus::Disconnected { reason: Some(r) } if !r.retriable() => {\n          sync_trace!(?r, \"non-retriable disconnect; aborting\");\n          return false;\n        },\n        _ => {},\n      }\n\n      // Attempt to connect\n      match target.clone().attempt_connect(token.clone()).await {\n        Ok(()) => {\n          sync_info!(attempt, \"reconnect successfully\");\n          return true;\n        },\n        Err(err) => {\n          sync_error!(attempt, %err, \"reconnect attempt failed\");\n          let reason = DisconnectedReason::from(err);\n          target.set_disconnected(reason);\n        },\n      }\n\n      // Exponential backoff: double, capped at max_delay\n      delay = std::cmp::min(delay * 2, self.config.max_delay);\n    }\n\n    // give up after max_attempts\n    sync_error!(\n      max_attempts = self.config.max_attempts,\n      \"max reconnect attempts reached; giving up\"\n    );\n    target.set_disconnected(DisconnectedReason::ReachMaximumRetry);\n    false\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use app_error::ErrorCode;\n  use async_trait::async_trait;\n  use shared_entity::response::AppResponseError;\n  use std::{\n    sync::{\n      atomic::{AtomicUsize, Ordering},\n      Arc,\n    },\n    time::Duration,\n  };\n  use tokio::sync::watch;\n  use tokio_util::sync::CancellationToken;\n\n  use super::{ReconnectTarget, ReconnectionManager, RetryConfig};\n  use crate::v2::controller::{spawn_reconnection, ConnectionStatus, DisconnectedReason};\n\n  // A more robust fake ReconnectTarget for driving tests\n  #[derive(Clone)]\n  struct FakeTarget {\n    status_tx: watch::Sender<ConnectionStatus>,\n    status_rx: watch::Receiver<ConnectionStatus>,\n    call_count: Arc<AtomicUsize>,\n    responses: Arc<tokio::sync::Mutex<Vec<Result<(), AppResponseError>>>>,\n    last_disconnect_reason: Arc<tokio::sync::Mutex<Option<DisconnectedReason>>>,\n  }\n\n  impl FakeTarget {\n    fn new(\n      initial_status: ConnectionStatus,\n      responses: Vec<Result<(), AppResponseError>>,\n    ) -> (Self, Arc<AtomicUsize>) {\n      let (tx, rx) = watch::channel(initial_status);\n      let call_count = Arc::new(AtomicUsize::new(0));\n      (\n        FakeTarget {\n          status_tx: tx,\n          status_rx: rx,\n          call_count: call_count.clone(),\n          responses: Arc::new(tokio::sync::Mutex::new(responses)),\n          last_disconnect_reason: Arc::new(tokio::sync::Mutex::new(None)),\n        },\n        call_count,\n      )\n    }\n\n    async fn get_last_disconnect_reason(&self) -> Option<DisconnectedReason> {\n      self.last_disconnect_reason.lock().await.clone()\n    }\n\n    async fn set_status(&self, status: ConnectionStatus) {\n      let _ = self.status_tx.send(status);\n    }\n  }\n\n  #[async_trait]\n  impl ReconnectTarget for FakeTarget {\n    fn status_channel(&self) -> &watch::Receiver<ConnectionStatus> {\n      &self.status_rx\n    }\n\n    async fn attempt_connect(self: Arc<Self>, _token: String) -> Result<(), AppResponseError> {\n      let idx = self.call_count.fetch_add(1, Ordering::SeqCst);\n      let responses_guard = self.responses.lock().await;\n      responses_guard.get(idx).cloned().unwrap_or_else(|| {\n        Err(AppResponseError {\n          code: ErrorCode::Internal,\n          message: \"no more responses configured\".into(),\n        })\n      })\n    }\n\n    fn set_disconnected(&self, reason: DisconnectedReason) {\n      // Store the reason for test verification\n      if let Ok(mut guard) = self.last_disconnect_reason.try_lock() {\n        *guard = Some(reason.clone());\n      }\n      let _ = self.status_tx.send(ConnectionStatus::Disconnected {\n        reason: Some(reason),\n      });\n    }\n  }\n\n  #[tokio::test]\n  async fn test_reconnect_succeeds_immediately() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 3,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(result, \"Reconnection should succeed immediately\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      1,\n      \"Should attempt connection exactly once\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_reconnect_succeeds_after_failures() {\n    let responses = vec![\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"first failure\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"second failure\".into(),\n      }),\n      Ok(()),\n    ];\n    let (fake_target, call_count) =\n      FakeTarget::new(ConnectionStatus::Disconnected { reason: None }, responses);\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(2),\n        max_attempts: 5,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(result, \"Reconnection should succeed on third attempt\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      3,\n      \"Should attempt connection three times\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_reconnect_fails_after_max_attempts() {\n    let responses = vec![\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"failure 1\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"failure 2\".into(),\n      }),\n    ];\n    let (fake_target, call_count) =\n      FakeTarget::new(ConnectionStatus::Disconnected { reason: None }, responses);\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 2,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(\n      !result,\n      \"Reconnection should fail after exhausting attempts\"\n    );\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      2,\n      \"Should attempt connection twice\"\n    );\n\n    // Verify that the target was set to disconnected with the correct reason\n    let disconnect_reason = fake_target.get_last_disconnect_reason().await;\n    assert!(matches!(\n      disconnect_reason,\n      Some(DisconnectedReason::ReachMaximumRetry)\n    ));\n  }\n\n  #[tokio::test]\n  async fn test_aborts_when_already_connecting() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Connecting {\n        cancel: CancellationToken::new(),\n      },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 3,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(!result, \"Should abort when already connecting\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection when already connecting\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_aborts_on_non_retriable_disconnect() {\n    let non_retriable_reason = DisconnectedReason::ReachMaximumRetry;\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected {\n        reason: Some(non_retriable_reason),\n      },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 3,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(!result, \"Should abort on non-retriable disconnect reason\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection for non-retriable disconnect\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_trigger_reconnect_with_valid_token() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    );\n\n    manager.set_access_token(\"valid_token\".into());\n\n    // Initial state should be no reconnection in progress\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n\n    manager.trigger_reconnect(\"test trigger\");\n\n    // Should set in_progress flag\n    assert!(manager.in_progress.load(Ordering::SeqCst));\n\n    // Wait a bit for the async reconnection to complete\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should have attempted connection and reset the in_progress flag\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_trigger_reconnect_without_token() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::new_for_target(target.clone());\n\n    // Don't set an access token\n    manager.trigger_reconnect(\"test trigger without token\");\n\n    // Should not set in_progress flag\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n\n    // Wait a bit to ensure no async work is done\n    tokio::time::sleep(Duration::from_millis(10)).await;\n\n    // Should not have attempted connection\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n  }\n\n  #[tokio::test]\n  async fn test_trigger_reconnect_when_already_in_progress() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(()), Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(50), // Longer delay to test concurrency\n        max_delay: Duration::from_millis(50),\n        max_attempts: 1,\n      },\n    );\n\n    manager.set_access_token(\"valid_token\".into());\n\n    // Start first reconnection\n    manager.trigger_reconnect(\"first trigger\");\n    assert!(manager.in_progress.load(Ordering::SeqCst));\n\n    // Try to start second reconnection while first is in progress\n    manager.trigger_reconnect(\"second trigger\");\n\n    // Wait for completion\n    tokio::time::sleep(Duration::from_millis(100)).await;\n\n    // Should have only attempted connection once (second call should be ignored)\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_exponential_backoff_timing() {\n    let responses = vec![\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 1\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 2\".into(),\n      }),\n      Ok(()),\n    ];\n    let (fake_target, call_count) =\n      FakeTarget::new(ConnectionStatus::Disconnected { reason: None }, responses);\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(100),\n        max_attempts: 3,\n      },\n    );\n\n    let start = std::time::Instant::now();\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n    let elapsed = start.elapsed();\n\n    assert!(result, \"Should eventually succeed\");\n    assert_eq!(call_count.load(Ordering::SeqCst), 3);\n\n    // Should have waited at least: initial_delay + (initial_delay * 2) = 10ms + 20ms = 30ms\n    assert!(\n      elapsed >= Duration::from_millis(25),\n      \"Should respect exponential backoff timing, elapsed: {:?}\",\n      elapsed\n    );\n  }\n\n  #[tokio::test]\n  async fn test_spawn_reconnection_integration() {\n    let retriable_reason = DisconnectedReason::Unexpected(\"connection lost\".into());\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    // Start the reconnection monitoring\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Initially not in progress\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n\n    // Simulate a retriable disconnection\n    fake_target\n      .set_status(ConnectionStatus::Disconnected {\n        reason: Some(retriable_reason),\n      })\n      .await;\n\n    // Give some time for the spawn_reconnection task to react\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should have triggered reconnection\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n  }\n\n  #[tokio::test]\n  async fn test_spawn_reconnection_ignores_non_retriable() {\n    let non_retriable_reason = DisconnectedReason::Unauthorized(\"invalid token\".into());\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Simulate a non-retriable disconnection\n    fake_target\n      .set_status(ConnectionStatus::Disconnected {\n        reason: Some(non_retriable_reason),\n      })\n      .await;\n\n    // Give some time for the spawn_reconnection task to potentially react\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should NOT have triggered reconnection\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_start_reconnect_status_triggers_reconnection() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Initially not in progress\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n\n    // Trigger StartReconnect status\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n\n    // Give some time for the spawn_reconnection task to react\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should have triggered reconnection\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n  }\n\n  #[tokio::test]\n  async fn test_start_reconnect_without_token() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    // Don't set access token\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Trigger StartReconnect status\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n\n    // Give some time for the spawn_reconnection task to react\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should NOT have triggered reconnection due to missing token\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_multiple_status_changes_handled_correctly() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(()), Ok(()), Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(5),\n        max_delay: Duration::from_millis(5),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Send multiple status changes\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n    tokio::time::sleep(Duration::from_millis(20)).await;\n\n    fake_target\n      .set_status(ConnectionStatus::Disconnected {\n        reason: Some(DisconnectedReason::Unexpected(\"network error\".into())),\n      })\n      .await;\n    tokio::time::sleep(Duration::from_millis(20)).await;\n\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n    tokio::time::sleep(Duration::from_millis(20)).await;\n\n    // Should have attempted reconnection multiple times\n    let final_count = call_count.load(Ordering::SeqCst);\n    assert!(\n      final_count >= 2,\n      \"Expected at least 2 reconnection attempts, got {}\",\n      final_count\n    );\n  }\n\n  // ============= STATUS TRANSITION EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_connected_status_ignored_by_retry_logic() {\n    // Test that retry logic aborts when status is Connected\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 1,\n      },\n    );\n\n    // Change to a Connected-like state (we'll simulate by changing to Connecting)\n    fake_target\n      .set_status(ConnectionStatus::Connecting {\n        cancel: CancellationToken::new(),\n      })\n      .await;\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    // Should abort when status indicates already connected/connecting\n    assert!(!result, \"Should abort when already connected/connecting\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_status_change_during_retry_sleep() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(50), // Long enough to change status during sleep\n        max_delay: Duration::from_millis(50),\n        max_attempts: 1,\n      },\n    );\n\n    // Start retry in background\n    let manager_clone = manager.clone();\n    let target_clone = target.clone();\n    let handle = tokio::spawn(async move {\n      manager_clone\n        .retry_with_exponential_backoff(target_clone, \"test_token\".into())\n        .await\n    });\n\n    // Change status to Connecting while retry is sleeping\n    tokio::time::sleep(Duration::from_millis(25)).await;\n    fake_target\n      .set_status(ConnectionStatus::Connecting {\n        cancel: CancellationToken::new(),\n      })\n      .await;\n\n    let result = handle.await.unwrap();\n\n    // Should abort after sleep when it checks status and finds Connecting\n    assert!(\n      !result,\n      \"Should abort when status changes to Connecting during sleep\"\n    );\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection\"\n    );\n  }\n\n  // ============= TOKEN EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_empty_string_token() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::new_for_target(target.clone());\n\n    manager.set_access_token(\"\".into()); // Empty string\n    manager.trigger_reconnect(\"test trigger\");\n\n    tokio::time::sleep(Duration::from_millis(20)).await;\n\n    // Should not attempt connection with empty token\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_token_change_during_reconnection() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(()), Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(20),\n        max_delay: Duration::from_millis(20),\n        max_attempts: 1,\n      },\n    );\n\n    manager.set_access_token(\"initial_token\".into());\n    manager.trigger_reconnect(\"first trigger\");\n\n    // Change token while first reconnection is in progress\n    tokio::time::sleep(Duration::from_millis(5)).await;\n    manager.set_access_token(\"new_token\".into());\n\n    // Trigger another reconnection\n    manager.trigger_reconnect(\"second trigger\");\n\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // First reconnection should complete, second should be ignored (in_progress)\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n  }\n\n  #[tokio::test]\n  async fn test_very_long_token() {\n    let long_token = \"a\".repeat(10000); // 10KB token\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    );\n\n    manager.set_access_token(long_token);\n    manager.trigger_reconnect(\"test trigger\");\n\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should handle very long tokens without issues\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n  }\n\n  // ============= ERROR HANDLING EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_specific_error_codes_handling() {\n    let error_test_cases = vec![\n      (ErrorCode::UserUnAuthorized, true), // Should trigger disconnect\n      (ErrorCode::NetworkError, true),\n      (ErrorCode::RequestTimeout, true),\n      (ErrorCode::Internal, true),\n    ];\n\n    for (error_code, should_set_disconnected) in error_test_cases {\n      let (fake_target, call_count) = FakeTarget::new(\n        ConnectionStatus::Disconnected { reason: None },\n        vec![Err(AppResponseError {\n          code: error_code,\n          message: \"test error\".into(),\n        })],\n      );\n      let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n      let manager = ReconnectionManager::with_config_for_target(\n        target.clone(),\n        RetryConfig {\n          initial_delay: Duration::from_millis(1),\n          max_delay: Duration::from_millis(1),\n          max_attempts: 1,\n        },\n      );\n\n      let result = manager\n        .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n        .await;\n\n      assert!(!result, \"Should fail for error code: {:?}\", error_code);\n      assert_eq!(call_count.load(Ordering::SeqCst), 1);\n\n      if should_set_disconnected {\n        let disconnect_reason = fake_target.get_last_disconnect_reason().await;\n        assert!(\n          disconnect_reason.is_some(),\n          \"Should set disconnect reason for error: {:?}\",\n          error_code\n        );\n      }\n    }\n  }\n\n  #[tokio::test]\n  async fn test_target_weak_reference_expired() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig::default(),\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    // Drop the target reference\n    drop(target);\n\n    // Trigger reconnection after target is dropped\n    manager.trigger_reconnect(\"test trigger\");\n\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should not crash and should reset in_progress flag\n    assert!(!manager.in_progress.load(Ordering::SeqCst));\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n  }\n\n  // ============= BACKOFF CALCULATION EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_backoff_delay_capping_behavior() {\n    let responses = vec![\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 1\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 2\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 3\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 4\".into(),\n      }),\n      Ok(()),\n    ];\n    let (fake_target, call_count) =\n      FakeTarget::new(ConnectionStatus::Disconnected { reason: None }, responses);\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(25), // Should cap at this value\n        max_attempts: 5,\n      },\n    );\n\n    let start = std::time::Instant::now();\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n    let elapsed = start.elapsed();\n\n    assert!(result, \"Should succeed\");\n    assert_eq!(call_count.load(Ordering::SeqCst), 5);\n\n    // Expected delays: 10ms, 20ms, 25ms (capped), 25ms (capped)\n    // Total minimum: 10 + 20 + 25 + 25 = 80ms\n    assert!(\n      elapsed >= Duration::from_millis(75),\n      \"Should respect delay capping: {:?}\",\n      elapsed\n    );\n    assert!(\n      elapsed < Duration::from_millis(200),\n      \"Should not exceed reasonable bounds: {:?}\",\n      elapsed\n    );\n  }\n\n  // ============= spawn_reconnection EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_multiple_spawn_reconnection_calls() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(()); 5],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(5),\n        max_delay: Duration::from_millis(5),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    // Start multiple spawn_reconnection tasks\n    for _ in 0..3 {\n      spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n    }\n\n    // Trigger reconnection\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should handle multiple spawn_reconnection tasks gracefully\n    // The in_progress flag should prevent multiple concurrent reconnections\n    let attempts = call_count.load(Ordering::SeqCst);\n    assert!(\n      attempts >= 1,\n      \"Should have at least one reconnection attempt\"\n    );\n    assert!(\n      attempts <= 3,\n      \"Should not have excessive attempts due to multiple spawn tasks: {}\",\n      attempts\n    );\n  }\n\n  #[tokio::test]\n  async fn test_state_consistency_during_concurrent_operations() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(()); 10],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Concurrent operations: status changes, trigger_reconnect calls, token changes\n    let tasks = vec![\n      // Status change task\n      {\n        let fake_target = fake_target.clone();\n        tokio::spawn(async move {\n          for i in 0..5 {\n            fake_target\n              .set_status(ConnectionStatus::StartReconnect)\n              .await;\n            tokio::time::sleep(Duration::from_millis(5)).await;\n            fake_target\n              .set_status(ConnectionStatus::Disconnected {\n                reason: Some(DisconnectedReason::Unexpected(\n                  format!(\"error {}\", i).into(),\n                )),\n              })\n              .await;\n            tokio::time::sleep(Duration::from_millis(5)).await;\n          }\n        })\n      },\n      // Direct trigger task\n      {\n        let manager = manager.clone();\n        tokio::spawn(async move {\n          for i in 0..3 {\n            manager.trigger_reconnect(&format!(\"manual trigger {}\", i));\n            tokio::time::sleep(Duration::from_millis(15)).await;\n          }\n        })\n      },\n      // Token change task\n      {\n        let manager = manager.clone();\n        tokio::spawn(async move {\n          for i in 0..3 {\n            manager.set_access_token(format!(\"token_{}\", i));\n            tokio::time::sleep(Duration::from_millis(20)).await;\n          }\n        })\n      },\n    ];\n\n    // Wait for all tasks to complete\n    for task in tasks {\n      let _ = task.await;\n    }\n\n    // Wait for all reconnections to settle\n    tokio::time::sleep(Duration::from_millis(100)).await;\n\n    // Verify final state consistency\n    assert!(\n      !manager.in_progress.load(Ordering::SeqCst),\n      \"in_progress flag should be reset\"\n    );\n\n    let final_attempts = call_count.load(Ordering::SeqCst);\n    assert!(\n      final_attempts > 0,\n      \"Should have attempted at least one reconnection\"\n    );\n    assert!(\n      final_attempts <= 15,\n      \"Should not have excessive attempts: {}\",\n      final_attempts\n    );\n  }\n\n  // ============= CONFIGURATION EDGE CASES =============\n\n  #[tokio::test]\n  async fn test_zero_max_attempts() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 0, // Zero attempts\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(!result, \"Should fail with zero max attempts\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection with zero max attempts\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_zero_initial_delay() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(0), // Zero delay\n        max_delay: Duration::from_millis(100),\n        max_attempts: 1,\n      },\n    );\n\n    let start = std::time::Instant::now();\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n    let elapsed = start.elapsed();\n\n    assert!(result, \"Should succeed with zero initial delay\");\n    assert_eq!(call_count.load(Ordering::SeqCst), 1);\n    // Should complete very quickly with zero delay\n    assert!(\n      elapsed < Duration::from_millis(50),\n      \"Should complete quickly with zero delay\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_max_delay_smaller_than_initial_delay() {\n    let responses = vec![\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 1\".into(),\n      }),\n      Err(AppResponseError {\n        code: ErrorCode::NetworkError,\n        message: \"fail 2\".into(),\n      }),\n      Ok(()),\n    ];\n    let (fake_target, call_count) =\n      FakeTarget::new(ConnectionStatus::Disconnected { reason: None }, responses);\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(50),\n        max_delay: Duration::from_millis(10), // Smaller than initial\n        max_attempts: 3,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(result, \"Should succeed despite config inconsistency\");\n    assert_eq!(call_count.load(Ordering::SeqCst), 3);\n  }\n\n  #[tokio::test]\n  async fn test_connecting_status_prevents_reconnection() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Connecting {\n        cancel: CancellationToken::new(),\n      },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target);\n    let manager = ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(1),\n        max_delay: Duration::from_millis(1),\n        max_attempts: 1,\n      },\n    );\n\n    let result = manager\n      .retry_with_exponential_backoff(target.clone(), \"test_token\".into())\n      .await;\n\n    assert!(!result, \"Should abort when already connecting\");\n    assert_eq!(\n      call_count.load(Ordering::SeqCst),\n      0,\n      \"Should not attempt connection when already connecting\"\n    );\n  }\n\n  #[tokio::test]\n  async fn test_spawn_reconnection_terminates_when_manager_dropped() {\n    let (fake_target, call_count) = FakeTarget::new(\n      ConnectionStatus::Disconnected { reason: None },\n      vec![Ok(())],\n    );\n    let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n    let manager = Arc::new(ReconnectionManager::with_config_for_target(\n      target.clone(),\n      RetryConfig {\n        initial_delay: Duration::from_millis(10),\n        max_delay: Duration::from_millis(10),\n        max_attempts: 1,\n      },\n    ));\n    manager.set_access_token(\"test_token\".into());\n\n    spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n    // Drop the manager\n    drop(manager);\n\n    // Try to trigger reconnection after manager is dropped\n    fake_target\n      .set_status(ConnectionStatus::StartReconnect)\n      .await;\n\n    tokio::time::sleep(Duration::from_millis(50)).await;\n\n    // Should NOT have triggered reconnection since manager was dropped\n    assert_eq!(call_count.load(Ordering::SeqCst), 0);\n  }\n\n  #[tokio::test]\n  async fn test_retriable_vs_non_retriable_disconnect_reasons() {\n    let test_cases = vec![\n      (DisconnectedReason::Unexpected(\"network error\".into()), true),\n      (DisconnectedReason::ResetWithoutClosingHandshake, true),\n      (DisconnectedReason::ReachMaximumRetry, false),\n      (\n        DisconnectedReason::Unauthorized(\"invalid token\".into()),\n        false,\n      ),\n      (\n        DisconnectedReason::UserDisconnect(\"manual disconnect\".into()),\n        false,\n      ),\n    ];\n\n    for (reason, should_reconnect) in test_cases {\n      let (fake_target, call_count) = FakeTarget::new(\n        ConnectionStatus::Disconnected { reason: None },\n        vec![Ok(())],\n      );\n      let target: Arc<dyn ReconnectTarget + Send + Sync> = Arc::new(fake_target.clone());\n\n      let manager = Arc::new(ReconnectionManager::with_config_for_target(\n        target.clone(),\n        RetryConfig {\n          initial_delay: Duration::from_millis(5),\n          max_delay: Duration::from_millis(5),\n          max_attempts: 1,\n        },\n      ));\n      manager.set_access_token(\"test_token\".into());\n\n      spawn_reconnection(Arc::downgrade(&manager), target.status_channel().clone());\n\n      // Trigger disconnection with the specific reason\n      fake_target\n        .set_status(ConnectionStatus::Disconnected {\n          reason: Some(reason.clone()),\n        })\n        .await;\n\n      tokio::time::sleep(Duration::from_millis(30)).await;\n      let attempts = call_count.load(Ordering::SeqCst);\n      if should_reconnect {\n        assert!(\n          attempts > 0,\n          \"Expected reconnection for retriable reason: {:?}\",\n          reason\n        );\n      } else {\n        assert_eq!(\n          attempts, 0,\n          \"Expected no reconnection for non-retriable reason: {:?}\",\n          reason\n        );\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/controller.rs",
    "content": "use super::db::{Db, DbHolder};\nuse super::{ChangedCollab, ObjectId, WorkspaceId};\nuse crate::entity::CollabType;\nuse crate::sync_trace;\nuse crate::v2::actor::{WorkspaceAction, WorkspaceControllerActor, WsConn};\nuse crate::v2::conn_retry::{ReconnectTarget, ReconnectionManager};\nuse app_error::ErrorCode;\nuse appflowy_proto::{Rid, WorkspaceNotification};\nuse async_trait::async_trait;\nuse collab::preclude::Collab;\nuse collab_rt_protocol::CollabRef;\nuse futures_core::Stream;\nuse futures_util::stream::SplitSink;\nuse shared_entity::response::AppResponseError;\nuse std::fmt::{Display, Formatter};\nuse std::sync::{Arc, Weak};\nuse tokio::sync::Mutex;\nuse tokio::time::interval;\nuse tokio_stream::wrappers::WatchStream;\nuse tokio_stream::StreamExt;\nuse tokio_tungstenite::tungstenite::error::ProtocolError;\nuse tokio_tungstenite::tungstenite::{Error, Message};\nuse tokio_util::sync::CancellationToken;\nuse tracing::instrument;\nuse yrs::block::ClientID;\n\n#[derive(Clone)]\npub struct WorkspaceController {\n  actor: Arc<WorkspaceControllerActor>,\n  connection_manager: Arc<ReconnectionManager>,\n}\n\nimpl WorkspaceController {\n  pub fn new(options: Options, workspace_db_path: &str) -> anyhow::Result<Self> {\n    let db = Db::open(options.workspace_id, options.uid, workspace_db_path)?;\n    Self::new_with_db(options, db)\n  }\n\n  pub fn new_with_rocksdb<T: Into<DbHolder>>(options: Options, db: T) -> anyhow::Result<Self> {\n    let db = Db::open_with_rocksdb(options.workspace_id, options.uid, db)?;\n    Self::new_with_db(options, db)\n  }\n\n  fn new_with_db(options: Options, db: Db) -> anyhow::Result<Self> {\n    let last_message_id = db.last_message_id()?;\n    let actor = WorkspaceControllerActor::new(db, options, last_message_id);\n\n    let conn_status = actor.status_channel().clone();\n    let connection_manager = Arc::new(ReconnectionManager::new(actor.clone()));\n    spawn_reconnection(Arc::downgrade(&connection_manager), conn_status);\n\n    Ok(Self {\n      actor,\n      connection_manager,\n    })\n  }\n\n  pub fn subscribe_changed_collab(&self) -> tokio::sync::broadcast::Receiver<ChangedCollab> {\n    self.actor.subscribe_changed_collab()\n  }\n\n  pub fn is_connected(&self) -> bool {\n    matches!(\n      &*self.actor.status_channel().borrow(),\n      ConnectionStatus::Connected { .. }\n    )\n  }\n\n  pub fn is_disconnected(&self) -> bool {\n    matches!(\n      &*self.actor.status_channel().borrow(),\n      ConnectionStatus::Disconnected { .. }\n    )\n  }\n\n  pub fn connect_state(&self) -> ConnectState {\n    ConnectState::from(&*self.actor.status_channel().borrow())\n  }\n\n  pub fn subscribe_connect_state(&self) -> impl Stream<Item = ConnectState> {\n    let status_rx = self.actor.status_channel().clone();\n    WatchStream::new(status_rx).map(|status| ConnectState::from(&status))\n  }\n\n  pub fn subscribe_notification(&self) -> tokio::sync::broadcast::Receiver<WorkspaceNotification> {\n    self.actor.subscribe_notification()\n  }\n\n  pub async fn connect(&self, access_token: String) -> anyhow::Result<()> {\n    if access_token.is_empty() {\n      return Err(anyhow::anyhow!(\"access token is empty\"));\n    }\n\n    self\n      .connection_manager\n      .set_access_token(access_token.clone());\n\n    let (tx, rx) = tokio::sync::oneshot::channel();\n    self.actor.trigger(WorkspaceAction::Connect {\n      ack: tx,\n      access_token,\n    });\n    rx.await??;\n    Ok(())\n  }\n\n  pub async fn disconnect(&self) -> anyhow::Result<()> {\n    let (tx, rx) = tokio::sync::oneshot::channel();\n    self.actor.trigger(WorkspaceAction::Disconnect(tx));\n    rx.await??;\n    Ok(())\n  }\n\n  pub async fn close(&mut self) -> anyhow::Result<()> {\n    self.disconnect().await\n  }\n\n  pub fn client_id(&self) -> ClientID {\n    self.actor.client_id()\n  }\n\n  pub fn workspace_id(&self) -> WorkspaceId {\n    *self.actor.workspace_id()\n  }\n\n  pub fn last_message_id(&self) -> Rid {\n    self.actor.last_message_id()\n  }\n\n  /// Binds a collaboration object to the actor and loads its data if needed.\n  /// This function sets up the necessary callbacks and observers to handle\n  /// collaboration updates and awareness changes.\n  ///\n  /// # Arguments\n  ///\n  /// * `actor`: Reference to the workspace controller actor managing the collaboration\n  /// * `collab_ref`: Reference to the collaboration object to be bound\n  /// * `collab_type`: The type of the collaboration (document, folder, etc.)\n  pub async fn bind_and_cache_collab_ref(\n    &self,\n    collab_ref: &CollabRef,\n    collab_type: CollabType,\n  ) -> anyhow::Result<()> {\n    WorkspaceControllerActor::bind_and_cache_collab_ref(&self.actor, collab_ref, collab_type).await\n  }\n\n  pub fn bind(&self, collab: &mut Collab, collab_type: CollabType) -> anyhow::Result<()> {\n    WorkspaceControllerActor::bind(&self.actor, collab, collab_type)\n  }\n\n  pub async fn cache_collab_ref(\n    &self,\n    object_id: ObjectId,\n    collab_ref: &CollabRef,\n    collab_type: CollabType,\n  ) -> anyhow::Result<()> {\n    self\n      .actor\n      .cache_collab_ref(object_id, collab_ref, collab_type);\n    Ok(())\n  }\n}\n\n#[cfg(debug_assertions)]\nimpl WorkspaceController {\n  pub fn enable_receive_message(&self) {\n    self\n      .actor\n      .skip_realtime_message\n      .store(false, std::sync::atomic::Ordering::SeqCst);\n  }\n\n  pub fn disable_receive_message(&self) {\n    self\n      .actor\n      .skip_realtime_message\n      .store(true, std::sync::atomic::Ordering::SeqCst);\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug)]\npub enum DisconnectedReason {\n  /// When disconnect reason is unexpected. ReconnectionManager will try to reconnect after a period of time\n  Unexpected(Arc<str>),\n  ResetWithoutClosingHandshake,\n  MessageDecode(Arc<str>),\n  ReachMaximumRetry,\n  MessageLoopEnd(Arc<str>),\n  CannotHandleReceiveMessage(Arc<str>),\n  UserDisconnect(Arc<str>),\n  ServerForceClose,\n  Unauthorized(Arc<str>),\n  PingTimeout,\n}\n\nimpl Display for DisconnectedReason {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      DisconnectedReason::Unexpected(reason) => write!(f, \"unexpected: {}\", reason),\n      DisconnectedReason::ResetWithoutClosingHandshake => {\n        write!(f, \"reset without closing handshake\")\n      },\n      DisconnectedReason::MessageDecode(reason) => write!(f, \"message decode: {}\", reason),\n      DisconnectedReason::ReachMaximumRetry => write!(f, \"reach maximum retry\"),\n      DisconnectedReason::MessageLoopEnd(reason) => write!(f, \"message loop end: {}\", reason),\n      DisconnectedReason::CannotHandleReceiveMessage(reason) => {\n        write!(f, \"cannot handle receive message: {}\", reason)\n      },\n      DisconnectedReason::UserDisconnect(reason) => write!(f, \"user disconnect: {}\", reason),\n      DisconnectedReason::Unauthorized(reason) => write!(f, \"unauthorized: {}\", reason),\n      DisconnectedReason::ServerForceClose => write!(f, \"server force close\"),\n      DisconnectedReason::PingTimeout => write!(f, \"ping timeout\"),\n    }\n  }\n}\n\nimpl From<appflowy_proto::Error> for DisconnectedReason {\n  fn from(value: appflowy_proto::Error) -> Self {\n    DisconnectedReason::MessageDecode(value.to_string().into())\n  }\n}\n\nimpl From<tokio_tungstenite::tungstenite::Error> for DisconnectedReason {\n  fn from(value: Error) -> Self {\n    match value {\n      Error::Io(err) => DisconnectedReason::Unexpected(err.to_string().into()),\n      Error::Protocol(p) => match p {\n        ProtocolError::ResetWithoutClosingHandshake => {\n          DisconnectedReason::ResetWithoutClosingHandshake\n        },\n        _ => DisconnectedReason::Unexpected(format!(\"{:?}\", p).into()),\n      },\n      _ => DisconnectedReason::MessageLoopEnd(value.to_string().into()),\n    }\n  }\n}\n\nimpl From<AppResponseError> for DisconnectedReason {\n  fn from(value: AppResponseError) -> Self {\n    match value.code {\n      ErrorCode::UserUnAuthorized => DisconnectedReason::Unauthorized(value.message.into()),\n      _ => DisconnectedReason::Unexpected(value.message.into()),\n    }\n  }\n}\n\nimpl DisconnectedReason {\n  pub fn retriable(&self) -> bool {\n    matches!(\n      self,\n      Self::Unexpected(..) | Self::ResetWithoutClosingHandshake\n    )\n  }\n\n  pub fn retriable_when_editing(&self) -> bool {\n    matches!(\n      self,\n      Self::Unexpected(..)\n        | Self::ResetWithoutClosingHandshake\n        | DisconnectedReason::Unauthorized(_)\n        | DisconnectedReason::ReachMaximumRetry\n    )\n  }\n}\n\n#[derive(Debug, Clone)]\npub enum ConnectionStatus {\n  Disconnected {\n    reason: Option<DisconnectedReason>,\n  },\n  Connecting {\n    cancel: CancellationToken,\n  },\n  Connected {\n    sink: Arc<Mutex<SplitSink<WsConn, Message>>>,\n    cancel: CancellationToken,\n  },\n  StartReconnect,\n}\n\nimpl ConnectionStatus {\n  pub fn disconnected_reason(&self) -> &Option<DisconnectedReason> {\n    match self {\n      ConnectionStatus::Disconnected { reason } => reason,\n      _ => &None,\n    }\n  }\n}\n\nimpl Default for ConnectionStatus {\n  fn default() -> Self {\n    ConnectionStatus::Disconnected { reason: None }\n  }\n}\n\nimpl Display for ConnectionStatus {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ConnectionStatus::Disconnected { reason: None } => write!(f, \"disconnected\"),\n      ConnectionStatus::Disconnected {\n        reason: Some(reason),\n      } => write!(f, \"disconnected: {:?}, \", reason),\n      ConnectionStatus::Connecting { .. } => write!(f, \"connecting\"),\n      ConnectionStatus::Connected { .. } => write!(f, \"connected\"),\n      ConnectionStatus::StartReconnect => write!(f, \"start reconnect\"),\n    }\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug)]\npub enum ConnectState {\n  Disconnected { reason: Option<DisconnectedReason> },\n  Connecting,\n  Connected,\n}\n\nimpl From<&ConnectionStatus> for ConnectState {\n  fn from(value: &ConnectionStatus) -> Self {\n    match value {\n      ConnectionStatus::Disconnected { reason } => ConnectState::Disconnected {\n        reason: reason.clone(),\n      },\n      ConnectionStatus::Connecting { .. } => ConnectState::Connecting,\n      ConnectionStatus::Connected { .. } => ConnectState::Connected,\n      ConnectionStatus::StartReconnect => ConnectState::Connecting,\n    }\n  }\n}\n\nimpl ConnectState {\n  pub fn is_connected(&self) -> bool {\n    matches!(self, ConnectState::Connected)\n  }\n}\n\n#[derive(Debug, Clone)]\npub struct Options {\n  /// Endpoint where server V2 protocol handler is listening on\n  /// (i.e. `ws://{server}:8000/ws/v2`).\n  pub url: String,\n  /// UUID of a workspace this controller is responsible for.\n  pub workspace_id: WorkspaceId,\n  /// Unique user ID assigned by the server.\n  pub uid: i64,\n  /// Unique identifier of current device\n  pub device_id: String,\n  /// If true, when connected, it will try to fetch info about new collabs\n  /// created while this client was offline.\n  pub sync_eagerly: bool,\n}\n\n#[async_trait]\nimpl ReconnectTarget for WorkspaceControllerActor {\n  fn status_channel(&self) -> &tokio::sync::watch::Receiver<ConnectionStatus> {\n    self.status_channel()\n  }\n\n  async fn attempt_connect(self: Arc<Self>, token: String) -> Result<(), AppResponseError> {\n    WorkspaceControllerActor::handle_connect(&self, token).await\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  fn set_disconnected(&self, reason: DisconnectedReason) {\n    self.set_connection_status(ConnectionStatus::Disconnected {\n      reason: Some(reason),\n    });\n  }\n}\n\npub fn spawn_reconnection(\n  manager: Weak<ReconnectionManager>,\n  mut connect_status_rx: tokio::sync::watch::Receiver<ConnectionStatus>,\n) {\n  tokio::spawn(async move {\n    // Using interval to check connection status every 10 minutes. This ensures connection\n    // monitoring continues even when the app is running in the background.\n    let mut interval = interval(std::time::Duration::from_secs(600));\n    interval.tick().await;\n\n    loop {\n      tokio::select! {\n        result = connect_status_rx.changed() => {\n          if result.is_err() {\n            sync_trace!(\"connection state change dropped\");\n            break;\n          }\n\n          match manager.upgrade() {\n            None => break ,\n            Some(manager) => {\n              check_and_reconnect(&manager, &connect_status_rx);\n            }\n          }\n        }\n        _ = interval.tick() => {\n          match manager.upgrade() {\n            None => break ,\n            Some(manager) => {\n              check_and_reconnect(&manager, &connect_status_rx);\n            }\n          }\n        }\n      }\n    }\n  });\n}\n\nfn check_and_reconnect(\n  manager: &Arc<ReconnectionManager>,\n  connect_status_rx: &tokio::sync::watch::Receiver<ConnectionStatus>,\n) {\n  sync_trace!(\n    \"check current connection status:{:?}\",\n    connect_status_rx.borrow()\n  );\n  match &*connect_status_rx.borrow() {\n    ConnectionStatus::Disconnected {\n      reason: Some(reason),\n    } => {\n      if reason.retriable() {\n        manager.trigger_reconnect(&reason.to_string());\n      }\n    },\n    ConnectionStatus::Disconnected { reason: None } => {},\n    ConnectionStatus::Connecting { .. } => {},\n    ConnectionStatus::Connected { .. } => {},\n    ConnectionStatus::StartReconnect => {\n      manager.trigger_reconnect(\"start reconnect\");\n    },\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/db.rs",
    "content": "use super::{ObjectId, WorkspaceId};\nuse crate::v2::actor::ActionSource;\nuse crate::{sync_debug, sync_error, sync_trace};\nuse anyhow::anyhow;\nuse appflowy_proto::Rid;\nuse client_api_entity::CollabType;\nuse collab::core::collab::CollabOptions;\nuse collab::core::origin::CollabOrigin;\nuse collab::core::transaction::DocTransactionExtension;\nuse collab::preclude::Collab;\nuse collab_plugins::local_storage::kv::doc::CollabKVAction;\nuse collab_plugins::local_storage::kv::{KVStore, KVTransactionDB, PersistenceError};\nuse collab_plugins::local_storage::rocksdb::kv_impl::KVTransactionDBRocksdbImpl;\nuse rand::random;\nuse std::str::FromStr;\nuse std::sync::{Arc, Weak};\nuse tracing::instrument;\nuse uuid::Uuid;\nuse yrs::block::ClientID;\nuse yrs::updates::decoder::Decode;\nuse yrs::{ReadTxn, StateVector, Transact, Update};\n\n#[derive(Clone)]\npub(crate) struct Db {\n  client_id: ClientID,\n  uid: i64,\n  workspace_id: Uuid,\n  inner: DbHolder,\n}\n\nimpl Db {\n  pub fn open(workspace_id: Uuid, uid: i64, path: &str) -> Result<Self, PersistenceError> {\n    let inner = KVTransactionDBRocksdbImpl::open(path)?;\n    let this = Self::open_with_rocksdb(workspace_id, uid, inner)?;\n    Ok(this)\n  }\n\n  pub fn open_with_rocksdb<T: Into<DbHolder>>(\n    workspace_id: Uuid,\n    uid: i64,\n    db: T,\n  ) -> Result<Self, PersistenceError> {\n    let db = db.into();\n    let instance = db.get()?;\n    let ops = instance.write_txn();\n    let client_id = ops.client_id(&workspace_id)?;\n    ops.commit_transaction()?;\n\n    sync_debug!(\"db client_id {}\", client_id);\n    Ok(Self {\n      client_id,\n      uid,\n      workspace_id,\n      inner: db,\n    })\n  }\n\n  pub fn client_id(&self) -> ClientID {\n    self.client_id\n  }\n\n  pub fn last_message_id(&self) -> Result<Rid, PersistenceError> {\n    let message_id = self\n      .inner\n      .get()?\n      .write_txn()\n      .last_message_id(&self.workspace_id)?;\n    Ok(message_id)\n  }\n\n  pub fn init_collab(\n    &self,\n    object_id: &ObjectId,\n    collab: &Collab,\n    collab_type: &CollabType,\n  ) -> Result<bool, PersistenceError> {\n    sync_trace!(\n      \"initializing collab {}/{}/{} in local db by {}\",\n      &self.workspace_id,\n      object_id,\n      collab_type,\n      self.uid\n    );\n    let tx = collab.transact();\n    let instance = self.inner.get()?;\n    let ops = instance.write_txn();\n\n    let workspace_id = self.workspace_id.to_string();\n    let object_id = object_id.to_string();\n\n    if !ops.is_exist(self.uid, &workspace_id, &object_id) {\n      match ops.create_new_doc(self.uid, &workspace_id, &object_id, &tx) {\n        Ok(_) => {\n          ops.commit_transaction()?;\n          sync_trace!(\n            \"Save collab {}/{}/{} to local db. store: {:#?}\",\n            &self.workspace_id,\n            object_id,\n            collab_type,\n            tx.store()\n          );\n          Ok(true)\n        },\n        Err(PersistenceError::DocumentAlreadyExist) => Ok(false),\n        Err(err) => Err(err),\n      }\n    } else {\n      Ok(false)\n    }\n  }\n\n  #[allow(dead_code)]\n  pub fn get_state_vector(&self, object_id: &ObjectId) -> Result<StateVector, PersistenceError> {\n    let instance = self.inner.get()?;\n    let ops = instance.read_txn();\n    let object_id = object_id.to_string();\n    let options = CollabOptions::new(object_id.to_string(), self.client_id);\n    let mut collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n    let mut txn = collab.transact_mut();\n    ops.load_doc_with_txn(\n      self.uid,\n      &self.workspace_id.to_string(),\n      &object_id.to_string(),\n      &mut txn,\n    )?;\n\n    Ok(txn.state_vector())\n  }\n\n  pub fn batch_get_state_vector(\n    &self,\n    object_ids: &[&ObjectId],\n  ) -> Result<Vec<(ObjectId, StateVector)>, PersistenceError> {\n    let instance = self.inner.get()?;\n    let ops = instance.read_txn();\n    let mut result = Vec::new();\n\n    for object_id in object_ids {\n      let get_state_vector = || -> Result<StateVector, PersistenceError> {\n        let object_id_str = object_id.to_string();\n        let options = CollabOptions::new(object_id_str.to_string(), self.client_id);\n        let mut collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n        let mut txn = collab.transact_mut();\n\n        ops.load_doc_with_txn(\n          self.uid,\n          &self.workspace_id.to_string(),\n          &object_id_str,\n          &mut txn,\n        )?;\n\n        Ok(txn.state_vector())\n      };\n\n      match get_state_vector() {\n        Ok(state_vector) => result.push((**object_id, state_vector)),\n        Err(_) => continue,\n      }\n    }\n\n    Ok(result)\n  }\n\n  /// Loads a document from the local database into the provided Collab instance.\n  ///\n  /// This function retrieves updates from the local database and applies it to the given\n  /// object. When the flush parameter is enabled, it will remove all individual updates and\n  /// store only the final document state and state vector,\n  ///\n  /// # Parameters\n  /// * `collab` - The mutable Collab instance to load the document data into\n  /// * `flush` - When true, consolidates storage by replacing all individual updates with\n  ///   the final document state\n  pub fn load(&self, collab: &mut Collab, flush: bool) -> Result<(), PersistenceError> {\n    let instance = self.inner.get()?;\n    let ops = instance.write_txn();\n    let object_id = ObjectId::from_str(collab.object_id())\n      .map_err(|err| PersistenceError::InvalidData(err.to_string()))?;\n    {\n      let mut txn = collab.transact_mut();\n      match ops.load_doc_with_txn(\n        self.uid,\n        &self.workspace_id.to_string(),\n        &object_id.to_string(),\n        &mut txn,\n      ) {\n        Ok(updates_applied) => {\n          sync_trace!(\n            \"restored collab {}, apply updates:{}, state: {:#?}\",\n            object_id,\n            updates_applied,\n            txn.store()\n          );\n        },\n        Err(PersistenceError::RecordNotFound(_)) => {\n          sync_debug!(\"collab {} not found in local db\", object_id);\n        },\n        Err(err) => return Err(err),\n      }\n    }\n\n    if flush {\n      let flush_doc = || {\n        let encoded = collab.transact().get_encoded_collab_v1();\n        ops.flush_doc(\n          self.uid,\n          &self.workspace_id.to_string(),\n          &object_id.to_string(),\n          encoded.state_vector.to_vec(),\n          encoded.doc_state.to_vec(),\n        )?;\n        ops.commit_transaction()?;\n        Ok::<_, PersistenceError>(())\n      };\n\n      if let Err(err) = flush_doc() {\n        sync_error!(\n          \"Failed to flush collab {} to local db: {}\",\n          collab.object_id(),\n          err\n        );\n      }\n    }\n\n    Ok(())\n  }\n\n  pub fn remove_doc(&self, object_id: &Uuid) -> Result<(), PersistenceError> {\n    let instance = self.inner.get()?;\n    let ops = instance.write_txn();\n    ops.delete_doc(\n      self.uid,\n      &self.workspace_id.to_string(),\n      &object_id.to_string(),\n    )?;\n    ops.commit_transaction()?;\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all, err)]\n  pub fn save_update(\n    &self,\n    object_id: &ObjectId,\n    message_id: Option<Rid>,\n    update_v1: &[u8],\n    action_source: ActionSource,\n  ) -> Result<Option<StateVector>, PersistenceError> {\n    if update_v1 == Update::EMPTY_V1 {\n      sync_trace!(\"skipping empty update {}\", object_id);\n      return Ok(None);\n    }\n    let instance = self.inner.get()?;\n    let ops = instance.write_txn();\n\n    sync_trace!(\n      \"persisting {} update for {}/{} by {}. update: {:#?}\",\n      action_source,\n      self.workspace_id,\n      object_id,\n      self.uid,\n      yrs::Update::decode_v1(update_v1).unwrap(),\n    );\n\n    let workspace_id = self.workspace_id.to_string();\n    let object_id = object_id.to_string();\n    let mut missing = None;\n    if ops.is_exist(self.uid, &workspace_id, &object_id) {\n      ops.push_update(self.uid, &workspace_id, &object_id, update_v1)?;\n    } else {\n      let update = yrs::Update::decode_v1(update_v1)?;\n      let doc = yrs::Doc::new();\n      let mut tx = doc.transact_mut();\n      tx.apply_update(update)?;\n      let sv = tx.state_vector();\n      if sv == StateVector::default() {\n        tracing::trace!(\n          \"collab {} initialized in incomplete state, missing updates found\",\n          object_id\n        );\n        missing = Some(sv);\n      }\n      ops.create_new_doc(self.uid, &workspace_id, &object_id, &tx)?;\n    }\n    if let Some(message_id) = message_id {\n      ops.update_last_message_id(&self.workspace_id, message_id)?;\n    }\n    ops.commit_transaction()?;\n    Ok(missing)\n  }\n}\n\npub trait CollabKVActionExt<'a>: CollabKVAction<'a>\nwhere\n  PersistenceError: From<<Self as KVStore<'a>>::Error>,\n{\n  fn client_id(&self, workspace_id: &Uuid) -> Result<ClientID, PersistenceError> {\n    let key = keys::make_client_id_key(workspace_id);\n    if let Some(existing) = self.get(&key)? {\n      let slice = existing.as_ref();\n      if slice.len() == 8 {\n        let client_id = ClientID::from_le_bytes(slice.try_into().unwrap());\n        return Ok(client_id);\n      }\n    }\n\n    // Keep client IDs 32bit, at least until client ID decoding\n    // bug is fixed (see: https://github.com/y-crdt/y-crdt/blob/826d15908105a349eb4a52e327e33cbc4720eda3/yrs/src/updates/decoder.rs#L144)\n    let client_id = random::<u32>() as u64;\n    sync_trace!(\n      \"generated new client id {} for workspace {}\",\n      client_id,\n      workspace_id\n    );\n    self.insert(key, client_id.to_le_bytes())?;\n    Ok(client_id)\n  }\n\n  fn last_message_id(&self, workspace_id: &WorkspaceId) -> Result<Rid, PersistenceError> {\n    let key = keys::make_last_message_id_key(workspace_id);\n    match self.get(&key)? {\n      None => Ok(Rid::default()),\n      Some(message_id) => {\n        let old_message_id = Rid::from_bytes(message_id.as_ref())\n          .map_err(|e| PersistenceError::InvalidData(e.to_string()))?;\n        Ok(old_message_id)\n      },\n    }\n  }\n\n  fn update_last_message_id(\n    &self,\n    workspace_id: &Uuid,\n    message_id: Rid,\n  ) -> Result<(), PersistenceError> {\n    let old_message_id = self.last_message_id(workspace_id)?;\n    let message_id = old_message_id.max(message_id);\n    let key = keys::make_last_message_id_key(workspace_id);\n    self.insert(key, message_id.into_bytes())?;\n    sync_trace!(\n      \"updated last message id for workspace {} to {}\",\n      workspace_id,\n      message_id\n    );\n    Ok(())\n  }\n}\n\nimpl<'a, T> CollabKVActionExt<'a> for T\nwhere\n  T: CollabKVAction<'a>,\n  PersistenceError: From<<T as KVStore<'a>>::Error>,\n{\n}\n\nmod keys {\n\n  // https://github.com/spacejam/sled\n  // sled performs prefix encoding on long keys with similar prefixes that are grouped together in a\n  // range, as well as suffix truncation to further reduce the indexing costs of long keys. Nodes\n  // will skip potentially expensive length and offset pointers if keys or values are all the same\n  // length (tracked separately, don't worry about making keys the same length as values), so it\n  // may improve space usage slightly if you use fixed-length keys or values. This also makes it\n  // easier to use structured access as well.\n  //\n  // DOC_SPACE\n  //     DOC_SPACE_OBJECT       object_id   TERMINATOR\n  //     DOC_SPACE_OBJECT_KEY     doc_id      DOC_STATE (state start)\n  //     DOC_SPACE_OBJECT_KEY     doc_id      TERMINATOR_HI_WATERMARK (state end)\n  //     DOC_SPACE_OBJECT_KEY     doc_id      DOC_STATE_VEC (state vector)\n  //     DOC_SPACE_OBJECT_KEY     doc_id      DOC_UPDATE clock TERMINATOR (update)\n  //\n  // SNAPSHOT_SPACE\n  //     SNAPSHOT_SPACE_OBJECT        object_id       TERMINATOR\n  //     SNAPSHOT_SPACE_OBJECT_KEY    snapshot_id     SNAPSHOT_UPDATE(snapshot)\n  //\n  // META_SPACE (extended notation)\n  //     CLIENT_ID            workspace_id  TERMINATOR\n  //     LAST_MESSAGE_ID      workspace_id  TERMINATOR\n\n  use smallvec::{smallvec, SmallVec};\n  use uuid::Uuid;\n\n  /// Prefix byte used for all metadata related keys.\n  pub const META_SPACE: u8 = 3;\n\n  /// Prefix byte used for client_id metadata for a given workspace.\n  pub const CLIENT_ID: u8 = 1;\n\n  /// Prefix byte used for last_message_id metadata for a given workspace.\n  pub const LAST_MESSAGE_ID: u8 = 2;\n\n  pub const TERMINATOR: u8 = 0;\n\n  pub fn make_client_id_key(workspace_id: &Uuid) -> SmallVec<[u8; 19]> {\n    // key: META_SPACE (1B) + CLIENT_ID (1B) + workspace_id (16B) + TERMINATOR (1B)\n    let mut key = smallvec![META_SPACE, CLIENT_ID];\n    key.extend_from_slice(workspace_id.as_bytes());\n    key.push(TERMINATOR);\n    key\n  }\n\n  pub fn make_last_message_id_key(workspace_id: &Uuid) -> SmallVec<[u8; 19]> {\n    // key: META_SPACE (1B) + LAST_MESSAGE_ID (1B) + workspace_id (16B) + TERMINATOR (1B)\n    let mut key = smallvec![META_SPACE, LAST_MESSAGE_ID];\n    key.extend_from_slice(workspace_id.as_bytes());\n    key.push(TERMINATOR);\n    key\n  }\n}\n\n/// A holder for RocksDB instances that supports both strong and weak references.\n///\n/// Since RocksDB should have only one instance at a time, this enum allows for\n/// proper reference management. Callers that need to hold a reference to the database\n/// should use a weak reference to avoid keeping the instance alive unnecessarily.\n#[derive(Clone)]\npub enum DbHolder {\n  /// Strong reference to RocksDB instance, maintains the instance alive as long as this reference exists\n  Strong(Arc<KVTransactionDBRocksdbImpl>),\n  /// Weak reference to RocksDB instance, allows the instance to be dropped when no strong references remain\n  Weak(Weak<KVTransactionDBRocksdbImpl>),\n}\n\nimpl DbHolder {\n  /// Attempts to get a strong reference to the RocksDB instance.\n  ///\n  /// If this is a strong holder, it simply clones the Arc.\n  /// If this is a weak holder, it attempts to upgrade the weak reference,\n  /// which will fail if the database has been dropped.\n  pub fn get(&self) -> anyhow::Result<Arc<KVTransactionDBRocksdbImpl>> {\n    match self {\n      Self::Strong(db) => Ok(db.clone()),\n      Self::Weak(db) => db.upgrade().ok_or_else(|| anyhow!(\"rocksdb was dropped\")),\n    }\n  }\n}\n\nimpl From<KVTransactionDBRocksdbImpl> for DbHolder {\n  fn from(value: KVTransactionDBRocksdbImpl) -> Self {\n    Self::Strong(Arc::new(value))\n  }\n}\n\nimpl From<Weak<KVTransactionDBRocksdbImpl>> for DbHolder {\n  fn from(value: Weak<KVTransactionDBRocksdbImpl>) -> Self {\n    Self::Weak(value)\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/v2/mod.rs",
    "content": "mod actor;\nmod compactor;\nmod conn_retry;\nmod controller;\nmod db;\npub type WorkspaceController = controller::WorkspaceController;\npub type WorkspaceControllerOptions = controller::Options;\n\npub use actor::ChangedCollab;\npub use controller::ConnectState;\npub use controller::DisconnectedReason;\npub use db::CollabKVActionExt;\n\npub type WorkspaceId = uuid::Uuid;\npub type ObjectId = uuid::Uuid;\n"
  },
  {
    "path": "libs/client-api/src/ws/client.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\n\nuse futures_util::stream::{SplitSink, SplitStream};\nuse futures_util::{SinkExt, StreamExt};\nuse parking_lot::RwLock;\n// use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};\nuse semver::Version;\nuse tokio::sync::broadcast::{channel, Receiver, Sender};\nuse tokio::sync::oneshot;\nuse tokio::sync::Mutex;\nuse tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;\nuse tokio_tungstenite::tungstenite::http::{HeaderMap, HeaderValue};\nuse tracing::{error, info, trace, warn};\n\nuse crate::ping::ServerFixIntervalPing;\nuse crate::retry::retry_connect;\nuse crate::ws::msg_queue::{AggregateMessageQueue, AggregateMessagesReceiver};\nuse crate::ws::{ConnectState, ConnectStateNotify, WSError, WebSocketChannel};\nuse client_websocket::{CloseCode, CloseFrame, Message, WebSocketStream};\nuse collab_rt_entity::user::UserMessage;\nuse collab_rt_entity::ClientCollabMessage;\nuse collab_rt_entity::ServerCollabMessage;\nuse collab_rt_entity::{RealtimeMessage, SystemMessage};\n\npub struct WSClientConfig {\n  /// specifies the number of messages that the channel can hold at any given\n  /// time. It is used to set the initial size of the channel's internal buffer\n  pub buffer_capacity: usize,\n  /// specifies the number of seconds between each ping message\n  pub ping_per_secs: u64,\n  /// specifies the number of pings that the client will start reconnecting\n  pub retry_connect_per_pings: u32,\n}\n\nimpl Default for WSClientConfig {\n  fn default() -> Self {\n    Self {\n      buffer_capacity: 2000,\n      ping_per_secs: 5,\n      retry_connect_per_pings: 6,\n    }\n  }\n}\n\n#[async_trait::async_trait]\npub trait WSClientHttpSender: Send + Sync {\n  async fn send_ws_msg(&self, device_id: &str, message: Message) -> Result<(), WSError>;\n}\n\n#[async_trait::async_trait]\npub trait WSClientConnectURLProvider: Send + Sync {\n  fn connect_ws_url(&self) -> String;\n  async fn connect_info(&self) -> Result<ConnectInfo, WSError>;\n}\n\ntype WeakChannel = Weak<WebSocketChannel<ServerCollabMessage>>;\ntype ChannelByObjectId = HashMap<String, Vec<WeakChannel>>;\npub type WSConnectStateReceiver = Receiver<ConnectState>;\n\npub(crate) type StateNotify = parking_lot::Mutex<ConnectStateNotify>;\n\n/// The maximum size allowed for a WebSocket message is 65,536 bytes. If the message exceeds\n/// 50960 bytes (to avoid occupying the entire space), it should be sent over HTTP instead.\nconst MAXIMUM_MESSAGE_SIZE: usize = 40960;\nconst MAXIMUM_BATCH_MESSAGE_SIZE: usize = 20480;\n\npub struct WSClient {\n  config: WSClientConfig,\n  state_notify: Arc<StateNotify>,\n  /// Sender used to send messages to the websocket.\n  ws_msg_sender: Sender<Message>,\n  rt_msg_sender: Sender<Vec<ClientCollabMessage>>,\n  http_sender: Arc<dyn WSClientHttpSender>,\n  user_channel: Arc<Sender<UserMessage>>,\n  channels: Arc<RwLock<ChannelByObjectId>>,\n  ping: Arc<Mutex<Option<ServerFixIntervalPing>>>,\n  stop_ws_msg_loop_tx: Mutex<Option<oneshot::Sender<()>>>,\n  aggregate_queue: Arc<AggregateMessageQueue>,\n\n  #[cfg(debug_assertions)]\n  skip_realtime_message: Arc<std::sync::atomic::AtomicBool>,\n  connect_provider: Arc<dyn WSClientConnectURLProvider>,\n}\nimpl WSClient {\n  pub fn new<H, C>(config: WSClientConfig, http_sender: H, connect_provider: C) -> Self\n  where\n    H: WSClientHttpSender + 'static,\n    C: WSClientConnectURLProvider + 'static,\n  {\n    let (ws_msg_sender, _) = channel(config.buffer_capacity);\n    let state_notify = Arc::new(parking_lot::Mutex::new(ConnectStateNotify::new()));\n    let channels = Arc::new(RwLock::new(HashMap::new()));\n    let ping = Arc::new(Mutex::from(None));\n    let http_sender = Arc::new(http_sender);\n    let (user_channel, _) = channel(1);\n    let (rt_msg_sender, _) = channel(config.buffer_capacity);\n    let connect_provider = Arc::new(connect_provider);\n    let aggregate_queue = Arc::new(AggregateMessageQueue::new(MAXIMUM_BATCH_MESSAGE_SIZE));\n    WSClient {\n      config,\n      state_notify,\n      ws_msg_sender,\n      rt_msg_sender,\n      http_sender,\n      user_channel: Arc::new(user_channel),\n      channels,\n      ping,\n      stop_ws_msg_loop_tx: Mutex::from(None),\n      aggregate_queue,\n\n      #[cfg(debug_assertions)]\n      skip_realtime_message: Default::default(),\n      connect_provider,\n    }\n  }\n\n  pub async fn connect(&self) -> Result<(), WSError> {\n    let connect_info = self.connect_provider.connect_info().await?;\n    let device_id = connect_info.device_id.clone();\n\n    if self.get_state().is_connecting() {\n      info!(\"websocket is connecting, skip connect request\");\n      return Ok(());\n    }\n    // 1. clean any previous connection\n    self.clean().await;\n\n    self.set_state(ConnectState::Connecting).await;\n    let (stop_ws_msg_loop_tx, stop_ws_msg_loop_rx) = oneshot::channel();\n    *self.stop_ws_msg_loop_tx.lock().await = Some(stop_ws_msg_loop_tx);\n\n    // 2. start connecting\n    let conn_result = retry_connect(\n      self.connect_provider.clone(),\n      Arc::downgrade(&self.state_notify),\n    )\n    .await;\n\n    // 3. handle websocket error when connecting or sending message\n    if let Err(err) = &conn_result {\n      match err {\n        WSError::AuthError(_) => self\n          .state_notify\n          .lock()\n          .set_state(ConnectState::Unauthorized),\n        _ => self.state_notify.lock().set_state(ConnectState::Lost),\n      }\n    }\n\n    // 4. after the connection is established, the client will start sending ping messages to the server\n    // at regular intervals to detect the connection status.\n    let (sink, stream) = conn_result?.split();\n    self.set_state(ConnectState::Connected).await;\n\n    // 5. start pinging\n    let pong_tx = self.start_ping().await;\n\n    // 6. spawn a task that continuously receives messages from the server\n    self.spawn_recv_server_message(\n      stream,\n      Arc::downgrade(&self.channels),\n      self.ws_msg_sender.clone(),\n      pong_tx,\n    );\n\n    let (tx, rx) = tokio::sync::mpsc::channel(100);\n    self.aggregate_queue.set_sender(tx).await;\n\n    // 7. spawn a task that continuously sending client message.\n    self.spawn_send_client_message(sink, &device_id, stop_ws_msg_loop_rx, rx);\n\n    // 8. start aggregating messages\n    // combine multiple messages into one message to reduce the number of messages sent over the network\n    self.spawn_aggregate_message();\n    Ok(())\n  }\n\n  fn spawn_aggregate_message(&self) {\n    let mut rx = self.rt_msg_sender.subscribe();\n    let weak_aggregate_queue = Arc::downgrade(&self.aggregate_queue);\n    tokio::spawn(async move {\n      while let Ok(msg) = rx.recv().await {\n        if let Some(aggregate_queue) = weak_aggregate_queue.upgrade() {\n          aggregate_queue.push(msg).await;\n        }\n      }\n    });\n  }\n\n  async fn start_ping(&self) -> tokio::sync::mpsc::Sender<()> {\n    let ping_sender = self.ws_msg_sender.clone();\n    let (pong_tx, pong_recv) = tokio::sync::mpsc::channel(1);\n    let mut ping = ServerFixIntervalPing::new(\n      Duration::from_secs(self.config.ping_per_secs),\n      self.state_notify.clone(),\n      ping_sender,\n      pong_recv,\n      self.config.retry_connect_per_pings,\n    );\n    ping.run();\n    *self.ping.lock().await = Some(ping);\n    pong_tx\n  }\n\n  // When\n  fn spawn_send_client_message(\n    &self,\n    mut sink: SplitSink<WebSocketStream, Message>,\n    device_id: &str,\n    mut stop_ws_msg_loop_rx: oneshot::Receiver<()>,\n    mut aggregate_msg_rx: AggregateMessagesReceiver,\n  ) {\n    let mut ws_msg_rx = self.ws_msg_sender.subscribe();\n    let weak_state_notify = Arc::downgrade(&self.state_notify);\n    let weak_http_sender = Arc::downgrade(&self.http_sender);\n    let device_id = device_id.to_string();\n\n    let handle_error = move |err| {\n      error!(\"{}\", err);\n      match weak_state_notify.upgrade() {\n        None => error!(\"websocket state_notify is dropped\"),\n        Some(state_notify) => match &err {\n          WSError::LostConnection(_) => state_notify.lock().set_state(ConnectState::Lost),\n          WSError::AuthError(_) => state_notify.lock().set_state(ConnectState::Unauthorized),\n          _ => {},\n        },\n      }\n    };\n\n    tokio::spawn(async move {\n      loop {\n        tokio::select! {\n           _ = &mut stop_ws_msg_loop_rx => break,\n           Ok(msg) = ws_msg_rx.recv() => {\n              if let Err(err) = send_message(&mut sink, &device_id, msg, &weak_http_sender).await {\n                if err.should_stop() {\n                  break;\n                }\n                handle_error(err);\n              }\n           }\n           Some(msg) = aggregate_msg_rx.recv() => {\n              if let Err(err) = send_message(&mut sink, &device_id, msg, &weak_http_sender).await {\n                if err.should_stop() {\n                  break;\n                }\n                handle_error(err);\n              }\n          }\n        }\n      }\n    });\n  }\n\n  fn spawn_recv_server_message(\n    &self,\n    mut stream: SplitStream<WebSocketStream>,\n    weak_collab_channels: Weak<RwLock<ChannelByObjectId>>,\n    sender: Sender<Message>,\n    pong_tx: tokio::sync::mpsc::Sender<()>,\n  ) {\n    #[cfg(debug_assertions)]\n    let cloned_skip_realtime_message = self.skip_realtime_message.clone();\n    let user_message_tx = self.user_channel.as_ref().clone();\n    tokio::spawn(async move {\n      while let Some(Ok(ws_msg)) = stream.next().await {\n        match ws_msg {\n          Message::Binary(data) => {\n            #[cfg(debug_assertions)]\n            {\n              if cloned_skip_realtime_message.load(std::sync::atomic::Ordering::SeqCst) {\n                continue;\n              }\n            }\n            match RealtimeMessage::decode(&data) {\n              Ok(msg) => match msg {\n                RealtimeMessage::Collab(collab_msg) => {\n                  match ServerCollabMessage::try_from(collab_msg) {\n                    Ok(collab_message) => {\n                      handle_collab_message(&weak_collab_channels, vec![collab_message]);\n                    },\n                    Err(err) => {\n                      error!(\"parser ServerCollabMessage failed: {:?}\", err);\n                    },\n                  }\n                },\n                RealtimeMessage::User(user_message) => {\n                  let _ = user_message_tx.send(user_message);\n                },\n                RealtimeMessage::System(sys_message) => match sys_message {\n                  SystemMessage::RateLimit(_limit) => {},\n                  SystemMessage::KickOff => {\n                    break;\n                  },\n                  SystemMessage::DuplicateConnection => {\n                    trace!(\"detect same ws connect from this device, closing the connection\");\n                    break;\n                  },\n                },\n                RealtimeMessage::ServerCollabV1(collab_messages) => {\n                  handle_collab_message(&weak_collab_channels, collab_messages);\n                },\n                RealtimeMessage::ClientCollabV2(_) | RealtimeMessage::ClientCollabV1(_) => {\n                  // The message from server should not be collab message.\n                  error!(\n                    \"received unexpected collab message from websocket: {:?}\",\n                    msg\n                  );\n                },\n              },\n              Err(err) => {\n                error!(\"parser RealtimeMessage failed: {:?}\", err);\n              },\n            }\n          },\n          // ping from server\n          Message::Ping(_) => match sender.send(Message::Pong(vec![])) {\n            Ok(_) => {},\n            Err(_e) => {\n              // if the sender returns an error, it means the receiver has been dropped\n              break;\n            },\n          },\n          Message::Close(close) => {\n            info!(\"websocket close: {:?}\", close);\n            break;\n          },\n          Message::Pong(_) => {\n            if let Err(err) = pong_tx.send(()).await {\n              error!(\"failed to receive server pong: {}\", err);\n              break;\n            }\n          },\n          _ => warn!(\"received unexpected message from websocket: {:?}\", ws_msg),\n        }\n      }\n    });\n  }\n\n  /// Return a [WebSocketChannel] that can be used to send messages to the websocket. Caller should\n  /// keep the channel alive as long as it wants to receive messages from the websocket.\n  pub fn subscribe_collab(\n    &self,\n    object_id: String,\n  ) -> Result<Arc<WebSocketChannel<ServerCollabMessage>>, WSError> {\n    let channel = Arc::new(WebSocketChannel::new(\n      &object_id,\n      self.rt_msg_sender.clone(),\n    ));\n    let mut collab_channels_guard = self.channels.write();\n\n    // remove the dropped channels\n    if let Some(channels) = collab_channels_guard.get_mut(&object_id) {\n      channels.retain(|channel| channel.upgrade().is_some());\n    }\n\n    collab_channels_guard\n      .entry(object_id)\n      .or_default()\n      .push(Arc::downgrade(&channel));\n\n    Ok(channel)\n  }\n\n  pub fn subscribe_user_changed(&self) -> Receiver<UserMessage> {\n    self.user_channel.subscribe()\n  }\n\n  pub fn subscribe_connect_state(&self) -> WSConnectStateReceiver {\n    self.state_notify.lock().subscribe()\n  }\n\n  pub fn is_connected(&self) -> bool {\n    self.state_notify.lock().state.is_connected()\n  }\n\n  pub async fn disconnect(&self) {\n    self.clean().await;\n\n    let _ = self.ws_msg_sender.send(Message::Close(Some(CloseFrame {\n      code: CloseCode::Normal,\n      reason: Cow::from(\"client disconnect\"),\n    })));\n\n    self.set_state(ConnectState::Lost).await;\n  }\n\n  async fn clean(&self) {\n    if let Some(old_stop_ws_tx) = self.stop_ws_msg_loop_tx.lock().await.take() {\n      let _ = old_stop_ws_tx.send(());\n    }\n\n    if let Some(old_ping) = self.ping.lock().await.as_ref() {\n      old_ping.stop().await;\n    }\n\n    self.aggregate_queue.clear().await;\n  }\n\n  pub fn send<M: Into<Message>>(&self, msg: M) -> Result<(), WSError> {\n    self.ws_msg_sender.send(msg.into()).unwrap();\n    Ok(())\n  }\n\n  pub fn get_state(&self) -> ConnectState {\n    self.state_notify.lock().state.clone()\n  }\n\n  async fn set_state(&self, state: ConnectState) {\n    self.state_notify.lock().set_state(state);\n  }\n}\n\n#[inline]\nfn handle_collab_message(\n  weak_collab_channels: &Weak<RwLock<ChannelByObjectId>>,\n  collab_messages: Vec<ServerCollabMessage>,\n) {\n  if let Some(collab_channels) = weak_collab_channels.upgrade() {\n    for collab_msg in collab_messages {\n      let object_id = collab_msg.object_id().to_owned();\n      // Iterate all channels and send the message to them.\n      if let Some(channels) = collab_channels.read().get(&object_id) {\n        for channel in channels.iter() {\n          if let Some(channel) = channel.upgrade() {\n            if cfg!(feature = \"sync_verbose_log\") {\n              trace!(\"receive server: {}\", collab_msg);\n            }\n            channel.forward_to_stream(collab_msg.clone());\n          }\n        }\n      }\n    }\n  } else {\n    warn!(\"channels are closed\");\n  }\n}\n#[cfg(debug_assertions)]\nimpl WSClient {\n  pub fn disable_receive_message(&self) {\n    self\n      .skip_realtime_message\n      .store(true, std::sync::atomic::Ordering::SeqCst);\n  }\n\n  pub fn enable_receive_message(&self) {\n    self\n      .skip_realtime_message\n      .store(false, std::sync::atomic::Ordering::SeqCst);\n  }\n}\n\nasync fn send_message(\n  sink: &mut SplitSink<WebSocketStream, Message>,\n  device_id: &str,\n  message: Message,\n  http_sender: &Weak<dyn WSClientHttpSender>,\n) -> Result<(), WSError> {\n  if message.is_binary() && message.len() > MAXIMUM_MESSAGE_SIZE {\n    if let Some(http_sender) = http_sender.upgrade() {\n      let cloned_device_id = device_id.to_string();\n      tokio::spawn(async move {\n        if let Err(err) = http_sender.send_ws_msg(&cloned_device_id, message).await {\n          error!(\"Failed to send WebSocket message over HTTP: {}\", err);\n        }\n      });\n    } else {\n      error!(\"The HTTP sender has been dropped, unable to send message.\");\n    }\n  } else {\n    sink.send(message).await.map_err(WSError::from)?;\n  }\n\n  Ok(())\n}\n\n#[derive(Clone, Eq, PartialEq)]\npub struct ConnectInfo {\n  pub access_token: String,\n  pub client_version: Version,\n  pub device_id: String,\n}\n\nimpl Display for ConnectInfo {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"access_token: xx, client_version: {}, device_id: {}\",\n      self.client_version, self.device_id\n    ))\n  }\n}\n\nimpl From<ConnectInfo> for HeaderMap {\n  fn from(info: ConnectInfo) -> HeaderMap {\n    let mut headers = HeaderMap::new();\n    headers.insert(\n      \"device-id\",\n      HeaderValue::from_str(&info.device_id).unwrap_or(HeaderValue::from_static(\"\")),\n    );\n    headers.insert(\n      \"client-version\",\n      HeaderValue::from_str(&info.client_version.to_string())\n        .unwrap_or(HeaderValue::from_static(\"unknown_client\")),\n    );\n    headers.insert(\n      AUTHORIZATION,\n      HeaderValue::from_str(&info.access_token).unwrap_or(HeaderValue::from_static(\"\")),\n    );\n    headers.insert(\n      \"connect-at\",\n      HeaderValue::from(chrono::Utc::now().timestamp()),\n    );\n    headers\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/ws/error.rs",
    "content": "use client_websocket::{Error, ProtocolError};\nuse reqwest::StatusCode;\n\n#[derive(Debug, thiserror::Error)]\npub enum WSError {\n  #[error(transparent)]\n  TungsteniteError(Error),\n\n  #[error(\"{0}\")]\n  LostConnection(String),\n\n  #[error(\"{0}\")]\n  Close(String),\n\n  #[error(\"Auth error: {0}\")]\n  AuthError(String),\n\n  #[error(\"Fail to send message via http: {0}\")]\n  Http(String),\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n}\n\nimpl WSError {\n  pub fn should_stop(&self) -> bool {\n    matches!(self, WSError::LostConnection(_) | WSError::Close(_))\n  }\n}\n\nimpl From<Error> for WSError {\n  fn from(value: Error) -> Self {\n    match &value {\n      Error::ConnectionClosed => WSError::LostConnection(value.to_string()),\n      Error::AlreadyClosed => WSError::Close(value.to_string()),\n      Error::Protocol(ProtocolError::SendAfterClosing) => WSError::Close(value.to_string()),\n      Error::Http(resp) => {\n        let status = resp.status();\n        if status == StatusCode::UNAUTHORIZED.as_u16() || status == StatusCode::NOT_FOUND.as_u16() {\n          WSError::AuthError(\"Unauthorized websocket connection\".to_string())\n        } else {\n          WSError::TungsteniteError(value)\n        }\n      },\n      _ => WSError::TungsteniteError(value),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/ws/handler.rs",
    "content": "use collab_rt_entity::ClientCollabMessage;\nuse collab_rt_entity::RealtimeMessage;\nuse futures_util::Sink;\nuse std::fmt::Debug;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse tokio::sync::broadcast::{channel, Sender};\nuse tokio::sync::mpsc::{unbounded_channel, UnboundedSender};\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse tracing::{trace, warn};\n\npub struct WebSocketChannel<T> {\n  #[allow(dead_code)]\n  object_id: String,\n  rt_msg_sender: Sender<Vec<ClientCollabMessage>>,\n  receiver: Sender<T>,\n}\n\nimpl<T> Drop for WebSocketChannel<T> {\n  fn drop(&mut self) {\n    #[cfg(feature = \"sync_verbose_log\")]\n    trace!(\"Drop WebSocketChannel {}\", self.object_id);\n  }\n}\n\nimpl<T> WebSocketChannel<T>\nwhere\n  T: Into<RealtimeMessage> + Clone + Send + Sync + 'static,\n{\n  pub fn new(object_id: &str, rt_msg_sender: Sender<Vec<ClientCollabMessage>>) -> Self {\n    let object_id = object_id.to_string();\n    let (receiver, _) = channel(1000);\n    Self {\n      object_id,\n      rt_msg_sender,\n      receiver,\n    }\n  }\n\n  /// Forward message to the stream returned by [WebSocketChannel::stream] method.\n  /// Calling this method to forward the server message to the receiver stream.\n  pub(crate) fn forward_to_stream(&self, msg: T) {\n    if let Err(err) = self.receiver.send(msg) {\n      warn!(\"Failed to send message to channel: {}\", err);\n    }\n  }\n\n  /// Use to send message to server via WebSocket.\n  pub fn sink(&self) -> BroadcastSink<Vec<ClientCollabMessage>> {\n    let (tx, mut rx) = unbounded_channel::<Vec<ClientCollabMessage>>();\n    let cloned_sender = self.rt_msg_sender.clone();\n    let object_id = self.object_id.clone();\n    tokio::spawn(async move {\n      while let Some(msg) = rx.recv().await {\n        let _ = cloned_sender.send(msg);\n      }\n\n      trace!(\"WebSocketChannel {} sink closed\", object_id);\n    });\n    BroadcastSink::new(tx)\n  }\n\n  /// Use to receive message from server via WebSocket.\n  pub fn stream(&self) -> UnboundedReceiverStream<Result<T, anyhow::Error>> {\n    let (tx, rx) = unbounded_channel::<Result<T, anyhow::Error>>();\n    let mut recv = self.receiver.subscribe();\n    let object_id = self.object_id.clone();\n    tokio::spawn(async move {\n      while let Ok(msg) = recv.recv().await {\n        if let Err(err) = tx.send(Ok(msg)) {\n          trace!(\"Failed to send message to channel stream: {}\", err);\n          break;\n        }\n      }\n      if cfg!(feature = \"sync_verbose_log\") {\n        trace!(\"WebSocketChannel {} stream closed\", object_id);\n      }\n    });\n    UnboundedReceiverStream::new(rx)\n  }\n}\n\npub struct BroadcastSink<T>(pub UnboundedSender<T>);\n\nimpl<T> BroadcastSink<T> {\n  pub fn new(tx: UnboundedSender<T>) -> Self {\n    Self(tx)\n  }\n}\n\nimpl<T> Sink<T> for BroadcastSink<T>\nwhere\n  T: Send + Sync + 'static + Debug,\n{\n  type Error = anyhow::Error;\n\n  fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n\n  fn start_send(self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> {\n    let _ = self.0.send(item);\n    Ok(())\n  }\n\n  fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n\n  fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/ws/mod.rs",
    "content": "mod client;\nmod error;\nmod handler;\nmod msg_queue;\nmod state;\n\npub use client::*;\npub use error::*;\npub use handler::*;\npub use state::*;\n"
  },
  {
    "path": "libs/client-api/src/ws/msg_queue.rs",
    "content": "use std::collections::{BinaryHeap, HashMap, HashSet};\nuse std::sync::{Arc, Weak};\nuse std::time::Duration;\n\nuse tokio::sync::mpsc;\nuse tokio::sync::Mutex;\nuse tokio::time::{sleep_until, Instant};\nuse tracing::{error, trace};\n\nuse client_websocket::Message;\nuse collab_rt_entity::{ClientCollabMessage, MsgId};\nuse collab_rt_entity::{MessageByObjectId, RealtimeMessage};\n\npub type AggregateMessagesSender = mpsc::Sender<Message>;\npub type AggregateMessagesReceiver = mpsc::Receiver<Message>;\n\npub struct AggregateMessageQueue {\n  maximum_payload_size: usize,\n  queue: Arc<Mutex<BinaryHeap<ClientCollabMessage>>>,\n  stop_tx: Mutex<Option<mpsc::Sender<()>>>,\n  seen_ids: Arc<Mutex<HashSet<SeenId>>>,\n}\n\nimpl AggregateMessageQueue {\n  pub fn new(maximum_payload_size: usize) -> Self {\n    Self {\n      maximum_payload_size,\n      queue: Default::default(),\n      stop_tx: Default::default(),\n      seen_ids: Arc::new(Default::default()),\n    }\n  }\n\n  pub async fn push(&self, msg: Vec<ClientCollabMessage>) {\n    let mut queue_guard = self.queue.lock().await;\n    let mut seen_ids_guard = self.seen_ids.lock().await;\n    for msg in msg.into_iter() {\n      if seen_ids_guard.insert(SeenId::from(&msg)) {\n        queue_guard.push(msg);\n      }\n    }\n  }\n\n  pub async fn clear(&self) {\n    self.queue.lock().await.clear();\n    self.seen_ids.lock().await.clear();\n  }\n\n  pub async fn set_sender(&self, sender: AggregateMessagesSender) {\n    let (tx, mut rx) = mpsc::channel(1);\n    if let Some(old_stop_tx) = self.stop_tx.lock().await.take() {\n      let _ = old_stop_tx.send(()).await;\n    }\n    *self.stop_tx.lock().await = Some(tx);\n\n    let maximum_payload_size = self.maximum_payload_size;\n    let weak_queue = Arc::downgrade(&self.queue);\n    let weak_seen_ids = Arc::downgrade(&self.seen_ids);\n    let interval_duration = Duration::from_millis(1000);\n    let mut next_tick = Instant::now() + interval_duration;\n    tokio::spawn(async move {\n      loop {\n        tokio::select! {\n          _ = rx.recv() => break,\n          _ = sleep_until(next_tick) => {\n            if let Some(queue) = weak_queue.upgrade() {\n              let (num_init_sync, num_messages) = handle_tick(&sender, &queue, maximum_payload_size, weak_seen_ids.clone()).await;\n              // To determine the next interval dynamically, consider factors such as the number of messages sent,\n              // their total size, and the current network type. This approach allows for more nuanced interval\n              // adjustments, optimizing for efficiency and responsiveness under varying conditions.\n              let duration = calculate_next_tick_duration(num_messages, num_init_sync, interval_duration);\n              next_tick = Instant::now() + duration;\n            } else {\n              break;\n            }\n          }\n        }\n      }\n    });\n  }\n}\n\nasync fn handle_tick(\n  sender: &AggregateMessagesSender,\n  queue: &Arc<Mutex<BinaryHeap<ClientCollabMessage>>>,\n  maximum_payload_size: usize,\n  weak_seen_ids: Weak<Mutex<HashSet<SeenId>>>,\n) -> (usize, usize) {\n  let (did_sent_seen_ids, messages_map) = next_batch_message(10, maximum_payload_size, queue).await;\n  if messages_map.is_empty() {\n    return (0, 0);\n  }\n\n  log_message_map(&messages_map);\n\n  // Send messages to server\n  send_batch_message(sender, messages_map).await;\n\n  // after sending messages, remove seen_ids\n  let num_init_sync = did_sent_seen_ids\n    .iter()\n    .filter(|id| id.is_init_sync)\n    .count();\n  let num_messages = did_sent_seen_ids.len();\n\n  if let Some(seen_ids) = weak_seen_ids.upgrade() {\n    let mut seen_lock = seen_ids.lock().await;\n    seen_lock.retain(|id| !did_sent_seen_ids.contains(id));\n  }\n  (num_init_sync, num_messages)\n}\n\n#[inline]\nasync fn send_batch_message(\n  sender: &AggregateMessagesSender,\n  messages_map: HashMap<String, Vec<ClientCollabMessage>>,\n) {\n  match RealtimeMessage::ClientCollabV2(MessageByObjectId(messages_map)).encode() {\n    Ok(data) => {\n      if let Err(e) = sender.send(Message::Binary(data)).await {\n        trace!(\"websocket channel close:{}, stop sending messages\", e);\n      }\n    },\n    Err(err) => {\n      error!(\"Failed to encode realtime message: {}\", err);\n    },\n  }\n}\n\n/// Gathers a batch of messages up to certain limits.\n///\n/// This function collects messages from a shared priority queue until reaching either the maximum number\n/// of initial sync messages or the maximum payload size. It groups messages by their object ID.\n///\n/// # Arguments\n/// - `maximum_init_sync`: Max number of initial sync messages allowed in a batch.\n/// - `maximum_payload_size`: Max total size of messages (in bytes) allowed in a batch.\n#[inline]\nasync fn next_batch_message(\n  maximum_init_sync: usize,\n  maximum_payload_size: usize,\n  queue: &Arc<Mutex<BinaryHeap<ClientCollabMessage>>>,\n) -> (HashSet<SeenId>, HashMap<String, Vec<ClientCollabMessage>>) {\n  let mut messages_map = HashMap::new();\n  let mut size = 0;\n  let mut init_sync_count = 0;\n  let mut lock_guard = queue.lock().await;\n  let mut seen_ids = HashSet::new();\n  while let Some(msg) = lock_guard.pop() {\n    size += msg.size();\n    if msg.is_init_sync() {\n      init_sync_count += 1;\n    }\n\n    seen_ids.insert(SeenId::from(&msg));\n    messages_map\n      .entry(msg.object_id().to_string())\n      .or_insert(vec![])\n      .push(msg);\n\n    if init_sync_count > maximum_init_sync {\n      break;\n    }\n    if size > maximum_payload_size {\n      break;\n    }\n  }\n\n  (seen_ids, messages_map)\n}\n\n#[inline]\n#[cfg(feature = \"sync_verbose_log\")]\nfn log_message_map(messages_map: &HashMap<String, Vec<ClientCollabMessage>>) {\n  // Define start and end signs\n  let start_sign = \"----- Start of Message List -----\";\n  let end_sign = \"------ End of Message List ------\";\n\n  let log_msg = messages_map\n    .iter()\n    .map(|(object_id, messages)| {\n      format!(\n        \"object_id:{}, num of messages:{}\",\n        object_id,\n        messages.len()\n      )\n    })\n    .collect::<Vec<_>>()\n    .join(\"\\n\"); // Joining with newline character\n\n  // Prepend the start sign and append the end sign to the log message\n  let log_msg = format!(\"{}\\n{}\\n{}\", start_sign, log_msg, end_sign);\n  tracing::debug!(\"Aggregate message list:\\n{}\", log_msg);\n}\n\n#[cfg(not(feature = \"sync_verbose_log\"))]\nfn log_message_map(_messages_map: &HashMap<String, Vec<ClientCollabMessage>>) {}\n\n#[derive(Eq, PartialEq, Hash)]\nstruct SeenId {\n  object_id: String,\n  msg_id: MsgId,\n  is_init_sync: bool,\n}\n\nimpl From<&ClientCollabMessage> for SeenId {\n  fn from(msg: &ClientCollabMessage) -> Self {\n    Self {\n      object_id: msg.object_id().to_string(),\n      msg_id: msg.msg_id(),\n      is_init_sync: msg.is_init_sync(),\n    }\n  }\n}\n\n/// Calculates the duration until the next tick based on the current state.\n///\n/// determines the appropriate interval until the next action should be taken, considering the\n/// number of messages and initial synchronizations.\n///\n/// - When the `test_fast_sync` feature is enabled, it always returns a fixed 1-second interval.\nfn calculate_next_tick_duration(\n  num_messages: usize,\n  num_init_sync: usize,\n  default_interval: Duration,\n) -> Duration {\n  if cfg!(feature = \"test_util\") {\n    Duration::from_millis(500)\n  } else if num_messages == 0 {\n    Duration::from_secs(1)\n  } else {\n    match num_init_sync {\n      0..=10 => default_interval,\n      11..=20 => Duration::from_secs(2),\n      _ => Duration::from_secs(4),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api/src/ws/state.rs",
    "content": "use tokio::sync::broadcast::{channel, Receiver, Sender};\nuse tracing::trace;\n\npub struct ConnectStateNotify {\n  pub(crate) state: ConnectState,\n  sender: Sender<ConnectState>,\n}\n\nimpl ConnectStateNotify {\n  pub(crate) fn new() -> Self {\n    let (sender, _) = channel(100);\n    Self {\n      state: ConnectState::Lost,\n      sender,\n    }\n  }\n\n  pub(crate) fn set_state(&mut self, state: ConnectState) {\n    if self.state != state {\n      trace!(\"[websocket]: {:?}\", state);\n      self.state = state.clone();\n      let _ = self.sender.send(state);\n    }\n  }\n\n  pub(crate) fn subscribe(&self) -> Receiver<ConnectState> {\n    self.sender.subscribe()\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug)]\npub enum ConnectState {\n  PingTimeout,\n  Connecting,\n  Connected,\n  Unauthorized,\n  Lost,\n}\n\nimpl ConnectState {\n  #[allow(dead_code)]\n  pub fn is_connecting(&self) -> bool {\n    matches!(self, ConnectState::Connecting)\n  }\n\n  pub fn is_connected(&self) -> bool {\n    matches!(self, ConnectState::Connected)\n  }\n\n  #[allow(dead_code)]\n  pub fn is_timeout(&self) -> bool {\n    matches!(self, ConnectState::PingTimeout)\n  }\n\n  #[allow(dead_code)]\n  pub fn is_lost(&self) -> bool {\n    matches!(self, ConnectState::Lost)\n  }\n}\n"
  },
  {
    "path": "libs/client-api-entity/Cargo.toml",
    "content": "[package]\nname = \"client-api-entity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\ncollab-entity = { workspace = true }\ngotrue-entity = { workspace = true }\nshared-entity = { workspace = true }\ncollab-rt-entity = { workspace = true }\ndatabase-entity.workspace = true\nuuid.workspace = true\n\ninfra = { workspace = true, optional = true }\n\n[features]\nfile_util = [\"infra/file_util\"]"
  },
  {
    "path": "libs/client-api-entity/src/id.rs",
    "content": "use uuid::Uuid;\n\npub fn user_awareness_object_id(user_uuid: &Uuid, workspace_id: &Uuid) -> Uuid {\n  Uuid::new_v5(\n    user_uuid,\n    format!(\"user_awareness:{}\", workspace_id).as_bytes(),\n  )\n}\n"
  },
  {
    "path": "libs/client-api-entity/src/lib.rs",
    "content": "pub mod id;\n\npub use collab_entity::*;\npub use collab_rt_entity::user::*;\npub use database_entity::dto::*;\npub use database_entity::file_dto::*;\npub use gotrue_entity::dto::*;\npub use shared_entity::dto::*;\n\n#[cfg(feature = \"file_util\")]\npub use infra::file_util;\n"
  },
  {
    "path": "libs/client-api-test/Cargo.toml",
    "content": "[package]\nname = \"client-api-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nbytes.workspace = true\nmime = \"0.3.17\"\nserde_json = \"1.0.111\"\ntokio = { workspace = true, features = [\"sync\"] }\ntokio-stream = \"0.1.14\"\ntracing.workspace = true\ncollab-folder.workspace = true\ncollab = { workspace = true }\ncollab-document.workspace = true\ncollab-user.workspace = true\nclient-api = { path = \"../client-api\", features = [\"test_util\"] }\ntempfile = \"3.9.0\"\nassert-json-diff = \"2.0.2\"\ndatabase-entity.workspace = true\ncollab-entity.workspace = true\nshared-entity.workspace = true\ncollab-database.workspace = true\ntracing-subscriber = { version = \"0.3.18\", features = [\n  \"registry\",\n  \"env-filter\",\n  \"ansi\",\n  \"json\",\n] }\nuuid.workspace = true\nlazy_static = \"1.4.0\"\ndotenvy = \"0.15.7\"\nreqwest.workspace = true\ngotrue.workspace = true\nclient-websocket.workspace = true\nanyhow.workspace = true\nserde = { version = \"1.0.199\", features = [\"derive\"] }\nhex = \"0.4.3\"\nasync-trait.workspace = true\ndashmap.workspace = true\nrand = \"0.9.1\"\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nweb-sys = { version = \"0.3\", features = [\"console\"] }\n\n\n[features]\ndefault = []\nai-test-enabled = []\nv2 = []"
  },
  {
    "path": "libs/client-api-test/src/assertion_utils.rs",
    "content": "use anyhow::{anyhow, Error};\nuse assert_json_diff::{assert_json_matches_no_panic, Config};\nuse async_trait::async_trait;\nuse serde_json::{json, Value};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::time::timeout;\nuse uuid::Uuid;\n\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::collab_state::SyncState;\nuse collab::core::origin::CollabOrigin;\nuse collab::lock::RwLock;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse shared_entity::dto::workspace_dto::CollabResponse;\nuse tracing::info;\n\nuse crate::async_utils::retry_with_backoff;\nuse crate::test_client_config::{AssertionConfig, RetryConfig};\n\n/// Trait for objects that can be asserted against JSON values\n#[async_trait]\npub trait JsonAssertable {\n  /// Gets the current JSON representation of the object\n  async fn get_json(&self) -> Result<Value, Error>;\n\n  /// Asserts that the object eventually matches the expected JSON\n  async fn assert_json_eventually(\n    &self,\n    expected: Value,\n    config: AssertionConfig,\n  ) -> Result<(), Error> {\n    retry_with_backoff(\n      || async {\n        let actual = self.get_json().await?;\n        if assert_json_matches_no_panic(&actual, &expected, Config::new(config.comparison_mode))\n          .is_ok()\n        {\n          Ok(())\n        } else {\n          Err(anyhow!(\n            \"JSON assertion failed.\\nExpected: {}\\nActual: {}\",\n            serde_json::to_string_pretty(&expected).unwrap_or_default(),\n            serde_json::to_string_pretty(&actual).unwrap_or_default()\n          ))\n        }\n      },\n      RetryConfig {\n        timeout: config.timeout,\n        poll_interval: config.retry_interval,\n        max_retries: config.max_retries,\n      },\n    )\n    .await\n  }\n\n  /// Asserts that a specific key in the JSON eventually matches the expected value\n  async fn assert_json_key_eventually(\n    &self,\n    key: &str,\n    expected: Value,\n    config: AssertionConfig,\n  ) -> Result<(), Error> {\n    retry_with_backoff(\n      || async {\n        let actual = self.get_json().await?;\n        let actual_value = actual\n          .get(key)\n          .ok_or_else(|| anyhow!(\"Key '{}' not found in JSON\", key))?;\n\n        if json!({key: actual_value}) == expected {\n          Ok(())\n        } else {\n          Err(anyhow!(\n            \"JSON key '{}' assertion failed.\\nExpected: {}\\nActual: {}\",\n            key,\n            serde_json::to_string_pretty(&expected).unwrap_or_default(),\n            serde_json::to_string_pretty(&actual).unwrap_or_default()\n          ))\n        }\n      },\n      RetryConfig {\n        timeout: config.timeout,\n        poll_interval: config.retry_interval,\n        max_retries: config.max_retries,\n      },\n    )\n    .await\n  }\n}\n\n/// Asserts that a server collab eventually matches the expected JSON\npub async fn assert_server_collab_eventually(\n  client: &mut client_api::Client,\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  expected: Value,\n  config: AssertionConfig,\n) -> Result<(), Error> {\n  retry_with_backoff(\n    || async {\n      let response = client\n        .get_collab(database_entity::dto::QueryCollabParams::new(\n          object_id,\n          collab_type,\n          workspace_id,\n        ))\n        .await\n        .map_err(|e| anyhow!(\"Failed to get collab: {}\", e))?;\n\n      let json = collab_response_to_json(&response, object_id)?;\n\n      if assert_json_matches_no_panic(&json, &expected, Config::new(config.comparison_mode)).is_ok()\n      {\n        info!(\n          \"Server collab assertion passed.\\nExpected: {}\\nActual: {}\",\n          serde_json::to_string_pretty(&expected).unwrap_or_default(),\n          serde_json::to_string_pretty(&json).unwrap_or_default()\n        );\n        Ok(())\n      } else {\n        Err(anyhow!(\n          \"Server collab assertion failed.\\nExpected: {}\\nActual: {}\",\n          serde_json::to_string_pretty(&expected).unwrap_or_default(),\n          serde_json::to_string_pretty(&json).unwrap_or_default()\n        ))\n      }\n    },\n    RetryConfig {\n      timeout: config.timeout,\n      poll_interval: config.retry_interval,\n      max_retries: config.max_retries,\n    },\n  )\n  .await\n}\n\n/// Converts a CollabResponse to JSON\nfn collab_response_to_json(response: &CollabResponse, object_id: Uuid) -> Result<Value, Error> {\n  let source = match response.encode_collab.version {\n    collab::entity::EncoderVersion::V1 => {\n      DataSource::DocStateV1(response.encode_collab.doc_state.to_vec())\n    },\n    collab::entity::EncoderVersion::V2 => {\n      DataSource::DocStateV2(response.encode_collab.doc_state.to_vec())\n    },\n  };\n\n  let options =\n    CollabOptions::new(object_id.to_string(), default_client_id()).with_data_source(source);\n\n  let collab = Collab::new_with_options(CollabOrigin::Empty, options)\n    .map_err(|e| anyhow!(\"Failed to create collab: {}\", e))?;\n\n  Ok(collab.to_json_value())\n}\n\n/// Waits for a sync state to reach SyncFinished\npub async fn wait_for_sync_complete(\n  sync_state_stream: &mut tokio_stream::wrappers::WatchStream<SyncState>,\n  current_state: SyncState,\n  timeout_duration: Duration,\n  collab: &Arc<RwLock<Collab>>,\n) -> Result<(), Error> {\n  if current_state == SyncState::SyncFinished {\n    return Ok(());\n  }\n\n  use tokio_stream::StreamExt;\n  let result = timeout(timeout_duration, async {\n    while let Some(state) = sync_state_stream.next().await {\n      if state == SyncState::SyncFinished {\n        return Ok(());\n      }\n    }\n    Err(anyhow!(\n      \"Sync state stream ended before reaching SyncFinished\"\n    ))\n  })\n  .await;\n\n  match result {\n    Ok(sync_result) => sync_result,\n    Err(_) => {\n      // Timeout occurred, check the actual sync state in collab\n      let lock = collab.read().await;\n      let actual_sync_state = lock.get_state().sync_state();\n      Err(anyhow!(\n        \"Timeout waiting for sync to complete. Current sync state in collab: {:?}\",\n        actual_sync_state\n      ))\n    },\n  }\n}\n"
  },
  {
    "path": "libs/client-api-test/src/async_utils.rs",
    "content": "use crate::test_client_config::RetryConfig;\nuse anyhow::{anyhow, Error};\nuse shared_entity::response::{AppResponseError, ErrorCode};\nuse std::future::Future;\nuse tokio::time::{sleep, timeout};\nuse tracing::{info, warn};\n\n/// Executes a future with retry logic and exponential backoff\npub async fn retry_with_backoff<T, F, Fut>(operation: F, config: RetryConfig) -> Result<T, Error>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = Result<T, Error>>,\n{\n  let mut attempt = 0;\n  let mut delay = config.poll_interval;\n  let max_delay = config.poll_interval * 10; // Cap maximum delay\n\n  let result = timeout(config.timeout, async {\n    loop {\n      match operation().await {\n        Ok(result) => return Ok(result),\n        Err(e) if attempt >= config.max_retries => {\n          return Err(anyhow!(\n            \"Max retries ({}) exceeded. Last error: {}\",\n            config.max_retries,\n            e\n          ));\n        },\n        Err(e) => {\n          attempt += 1;\n          warn!(\n            \"Operation failed (attempt {}/{}): {}. Retrying in {:?}\",\n            attempt, config.max_retries, e, delay\n          );\n\n          sleep(delay).await;\n          // Exponential backoff with jitter\n          delay = std::cmp::min(delay * 2, max_delay);\n        },\n      }\n    }\n  })\n  .await;\n\n  match result {\n    Ok(value) => value,\n    Err(_) => Err(anyhow!(\"Operation timed out after {:?}\", config.timeout)),\n  }\n}\n\n/// Executes a future with retry logic and constant intervals (no exponential backoff)\npub async fn retry_with_constant_interval<T, F, Fut>(\n  operation: F,\n  config: RetryConfig,\n) -> Result<T, Error>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = Result<T, Error>>,\n{\n  let mut attempt = 0;\n\n  let result = timeout(config.timeout, async {\n    loop {\n      match operation().await {\n        Ok(result) => return Ok(result),\n        Err(e) if attempt >= config.max_retries => {\n          return Err(anyhow!(\n            \"Max retries ({}) exceeded. Last error: {}\",\n            config.max_retries,\n            e\n          ));\n        },\n        Err(e) => {\n          attempt += 1;\n          warn!(\n            \"Operation failed (attempt {}/{}): {}. Retrying in {:?}\",\n            attempt, config.max_retries, e, config.poll_interval\n          );\n\n          sleep(config.poll_interval).await;\n        },\n      }\n    }\n  })\n  .await;\n\n  match result {\n    Ok(value) => value,\n    Err(_) => Err(anyhow!(\"Operation timed out after {:?}\", config.timeout)),\n  }\n}\n\n/// Executes an API operation with retry logic and constant intervals, returning AppResponseError\npub async fn retry_api_with_constant_interval<T, F, Fut>(\n  operation: F,\n  config: RetryConfig,\n) -> Result<T, shared_entity::response::AppResponseError>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = Result<T, shared_entity::response::AppResponseError>>,\n{\n  let mut attempt = 0;\n\n  let result = timeout(config.timeout, async {\n    loop {\n      match operation().await {\n        Ok(result) => {\n          info!(\n            \"Operation succeeded (attempt {}/{})\",\n            attempt + 1,\n            config.max_retries,\n          );\n          return Ok(result);\n        },\n        Err(e) if attempt >= config.max_retries => {\n          return Err(AppResponseError {\n            code: ErrorCode::Internal,\n            message: format!(\n              \"Max retries ({}) exceeded. Last error: {}\",\n              config.max_retries, e.message\n            )\n            .into(),\n          });\n        },\n        Err(e) => {\n          attempt += 1;\n          warn!(\n            \"Operation failed (attempt {}/{}): {}. Retrying in {:?}\",\n            attempt, config.max_retries, e.message, config.poll_interval\n          );\n\n          sleep(config.poll_interval).await;\n        },\n      }\n    }\n  })\n  .await;\n\n  match result {\n    Ok(value) => value,\n    Err(_) => Err(shared_entity::response::AppResponseError {\n      code: shared_entity::response::ErrorCode::RequestTimeout,\n      message: format!(\"Operation timed out after {:?}\", config.timeout).into(),\n    }),\n  }\n}\n\n/// Polls a condition until it returns true or timeout is reached\npub async fn poll_until<F, Fut>(condition: F, config: RetryConfig) -> Result<(), Error>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = bool>,\n{\n  retry_with_backoff(\n    || async {\n      if condition().await {\n        Ok(())\n      } else {\n        Err(anyhow!(\"Condition not met\"))\n      }\n    },\n    config,\n  )\n  .await\n}\n\n/// Polls a function until it returns Some(T) or timeout is reached\npub async fn poll_until_some<T, F, Fut>(operation: F, config: RetryConfig) -> Result<T, Error>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = Option<T>>,\n{\n  retry_with_backoff(\n    || async {\n      match operation().await {\n        Some(value) => Ok(value),\n        None => Err(anyhow!(\"Operation returned None\")),\n      }\n    },\n    config,\n  )\n  .await\n}\n\n/// Polls a function until it returns Ok(T) or timeout is reached\npub async fn poll_until_ok<T, E, F, Fut>(operation: F, config: RetryConfig) -> Result<T, Error>\nwhere\n  F: Fn() -> Fut,\n  Fut: Future<Output = Result<T, E>>,\n  E: std::error::Error + Send + Sync + 'static,\n{\n  retry_with_backoff(\n    || async {\n      operation()\n        .await\n        .map_err(|e| anyhow!(\"Operation failed: {}\", e))\n    },\n    config,\n  )\n  .await\n}\n\n/// Waits for multiple conditions to be met within the timeout\npub async fn wait_for_all_conditions<F>(\n  conditions: Vec<F>,\n  config: RetryConfig,\n) -> Result<(), Error>\nwhere\n  F: Fn() -> Box<dyn Future<Output = bool> + Send + Unpin>,\n{\n  poll_until(\n    || async {\n      for condition in &conditions {\n        if !condition().await {\n          return false;\n        }\n      }\n      true\n    },\n    config,\n  )\n  .await\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use std::sync::{Arc, Mutex};\n  use std::time::Duration;\n\n  #[tokio::test]\n  async fn test_retry_with_backoff_success() {\n    let counter = Arc::new(Mutex::new(0));\n    let counter_clone = counter.clone();\n\n    let result = retry_with_backoff(\n      move || {\n        let counter = counter_clone.clone();\n        async move {\n          let mut count = counter.lock().unwrap();\n          *count += 1;\n          if *count >= 3 {\n            Ok(\"success\")\n          } else {\n            Err(anyhow!(\"not ready\"))\n          }\n        }\n      },\n      RetryConfig {\n        timeout: Duration::from_secs(5),\n        poll_interval: Duration::from_millis(10),\n        max_retries: 5,\n      },\n    )\n    .await;\n\n    assert!(result.is_ok());\n    assert_eq!(*counter.lock().unwrap(), 3);\n  }\n\n  #[tokio::test]\n  async fn test_poll_until_some() {\n    let counter = Arc::new(Mutex::new(0));\n    let counter_clone = counter.clone();\n\n    let result = poll_until_some(\n      move || {\n        let counter = counter_clone.clone();\n        async move {\n          let mut count = counter.lock().unwrap();\n          *count += 1;\n          if *count >= 2 {\n            Some(\"found\")\n          } else {\n            None\n          }\n        }\n      },\n      RetryConfig {\n        timeout: Duration::from_secs(5),\n        poll_interval: Duration::from_millis(10),\n        max_retries: 5,\n      },\n    )\n    .await;\n\n    assert!(result.is_ok());\n    assert_eq!(result.unwrap(), \"found\");\n  }\n}\n"
  },
  {
    "path": "libs/client-api-test/src/client.rs",
    "content": "use client_api::{Client, ClientConfiguration};\nuse lazy_static::lazy_static;\nuse std::borrow::Cow;\nuse std::env;\nuse tracing::warn;\nuse uuid::Uuid;\n\n// When running appflowy with Nginx, you need to set the environment variables in your .env file.\n// LOCALHOST_URL=http://localhost\n// LOCALHOST_WS=ws://localhost/ws/v1\n// LOCALHOST_WS_V2=ws://localhost/ws/v2\n// LOCALHOST_GOTRUE=http://localhost/gotrue\n\nlazy_static! {\n  pub static ref LOCALHOST_URL: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_URL\", \"http://localhost:8000\");\n  pub static ref LOCALHOST_WS: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_WS\", \"ws://localhost:8000/ws/v1\");\n  pub static ref LOCALHOST_WS_V2: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_WS_V2\", \"ws://localhost:8000/ws/v2\");\n  pub static ref LOCALHOST_GOTRUE: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_GOTRUE\", \"http://localhost:9999\");\n}\n\n#[allow(dead_code)]\nfn get_env_var<'default>(key: &str, default: &'default str) -> Cow<'default, str> {\n  dotenvy::dotenv().ok();\n  match env::var(key) {\n    Ok(value) => Cow::Owned(value),\n    Err(_) => {\n      warn!(\"could not read env var {}: using default: {}\", key, default);\n      Cow::Borrowed(default)\n    },\n  }\n}\n\n/// Return a client that connects to the local host. It requires to run the server locally.\n/// ```shell\n/// ./script/run_local_server.sh\n/// ```\npub fn localhost_client() -> Client {\n  let device_id = Uuid::new_v4().to_string();\n  localhost_client_with_device_id(&device_id)\n}\n\npub fn localhost_client_with_device_id(device_id: &str) -> Client {\n  Client::new(\n    &LOCALHOST_URL,\n    &LOCALHOST_WS,\n    &LOCALHOST_GOTRUE,\n    device_id,\n    ClientConfiguration::default(),\n    \"0.9.0\",\n  )\n}\n\npub async fn workspace_id_from_client(c: &Client) -> Uuid {\n  c.get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id\n}\n"
  },
  {
    "path": "libs/client-api-test/src/database_util.rs",
    "content": "use async_trait::async_trait;\nuse collab::entity::EncodedCollab;\nuse collab::lock::RwLock;\nuse collab::preclude::ClientID;\nuse collab_database::database_trait::{DatabaseCollabReader, EncodeCollabByOid};\nuse collab_database::error::DatabaseError;\nuse collab_database::rows::{DatabaseRow, RowId};\nuse collab_entity::CollabType;\nuse dashmap::DashMap;\nuse database_entity::dto::QueryCollabResult::{Failed, Success};\nuse database_entity::dto::{QueryCollab, QueryCollabParams};\nuse std::sync::Arc;\nuse tracing::error;\nuse uuid::Uuid;\n\npub struct TestDatabaseCollabService {\n  pub api_client: client_api::Client,\n  pub workspace_id: Uuid,\n  pub client_id: ClientID,\n  cache: Arc<DashMap<RowId, Arc<RwLock<DatabaseRow>>>>,\n}\n\nimpl TestDatabaseCollabService {\n  pub fn new(api_client: client_api::Client, workspace_id: Uuid, client_id: ClientID) -> Self {\n    Self {\n      api_client,\n      workspace_id,\n      client_id,\n      cache: Arc::new(DashMap::new()),\n    }\n  }\n}\n\n#[async_trait]\nimpl DatabaseCollabReader for TestDatabaseCollabService {\n  async fn reader_client_id(&self) -> ClientID {\n    self.client_id\n  }\n\n  async fn reader_get_collab(\n    &self,\n    object_id: &str,\n    collab_type: CollabType,\n  ) -> Result<EncodedCollab, DatabaseError> {\n    let object_id = Uuid::parse_str(object_id)?;\n    let params = QueryCollabParams {\n      workspace_id: self.workspace_id,\n      inner: QueryCollab {\n        object_id,\n        collab_type,\n      },\n    };\n    let resp = self\n      .api_client\n      .get_collab(params)\n      .await\n      .map_err(|err| DatabaseError::Internal(err.into()))?;\n    Ok(resp.encode_collab)\n  }\n\n  async fn reader_batch_get_collabs(\n    &self,\n    object_ids: Vec<String>,\n    collab_type: CollabType,\n  ) -> Result<EncodeCollabByOid, DatabaseError> {\n    let params = object_ids\n      .into_iter()\n      .flat_map(|object_id| match Uuid::parse_str(&object_id) {\n        Ok(object_id) => Ok(QueryCollab::new(object_id, collab_type)),\n        Err(err) => Err(err),\n      })\n      .collect();\n    let results = self\n      .api_client\n      .batch_get_collab(&self.workspace_id, params)\n      .await\n      .map_err(|err| DatabaseError::Internal(err.into()))?;\n    Ok(\n      results\n        .0\n        .into_iter()\n        .flat_map(|(object_id, result)| match result {\n          Success { encode_collab_v1 } => match EncodedCollab::decode_from_bytes(&encode_collab_v1)\n          {\n            Ok(encode) => Some((object_id.to_string(), encode)),\n            Err(err) => {\n              error!(\"Failed to decode collab: {}\", err);\n              None\n            },\n          },\n          Failed { error } => {\n            error!(\"Failed to get {} update: {}\", object_id, error);\n            None\n          },\n        })\n        .collect::<EncodeCollabByOid>(),\n    )\n  }\n\n  fn database_row_cache(&self) -> Option<Arc<DashMap<RowId, Arc<RwLock<DatabaseRow>>>>> {\n    Some(self.cache.clone())\n  }\n}\n"
  },
  {
    "path": "libs/client-api-test/src/lib.rs",
    "content": "mod client;\nmod database_util;\nmod log;\nmod user;\n\n// New modules for better organization\nmod assertion_utils;\nmod async_utils;\nmod test_client_config;\nmod workspace_ops;\n\npub use client::*;\npub use log::*;\npub use user::*;\n\n// Export new modules\npub use assertion_utils::*;\npub use async_utils::*;\npub use test_client_config::*;\npub use workspace_ops::*;\n\n#[cfg(not(feature = \"v2\"))]\nmod test_client;\n\n#[cfg(feature = \"v2\")]\nmod test_client_v2;\n\n#[cfg(not(feature = \"v2\"))]\npub use test_client::*;\n\n#[cfg(feature = \"v2\")]\npub use test_client_v2::*;\n"
  },
  {
    "path": "libs/client-api-test/src/log.rs",
    "content": "#[cfg(not(target_arch = \"wasm32\"))]\nuse {\n  std::sync::Once,\n  tracing_subscriber::{fmt::Subscriber, util::SubscriberInitExt, EnvFilter},\n};\n\n#[cfg(not(target_arch = \"wasm32\"))]\nfn get_bool_from_env_var(env_var_name: &str) -> bool {\n  match std::env::var(env_var_name) {\n    Ok(value) => match value.to_lowercase().as_str() {\n      \"true\" | \"1\" => true,\n      \"false\" | \"0\" => false,\n      _ => false,\n    },\n    Err(_) => false,\n  }\n}\n\npub fn load_env() {\n  // load once\n  static START: Once = Once::new();\n  START.call_once(|| {\n    dotenvy::dotenv().ok();\n  });\n}\n\npub fn ai_test_enabled() -> bool {\n  // In appflowy GitHub CI, we enable 'ai-test-enabled' feature by default, so even if the env var is not set,\n  // we still enable the local ai test.\n  if cfg!(feature = \"ai-test-enabled\") {\n    return true;\n  }\n\n  load_env();\n  get_bool_from_env_var(\"AI_TEST_ENABLED\")\n}\n\n#[cfg(not(target_arch = \"wasm32\"))]\npub fn setup_log() {\n  if get_bool_from_env_var(\"DISABLE_CI_TEST_LOG\") {\n    return;\n  }\n\n  static START: Once = Once::new();\n  START.call_once(|| {\n    let level = std::env::var(\"RUST_LOG\").unwrap_or(\"debug\".to_string());\n    let mut filters = vec![];\n    filters.push(format!(\"client_api={}\", level));\n    filters.push(format!(\"client_api_test={}\", level));\n    filters.push(format!(\"appflowy_cloud={}\", level));\n    filters.push(format!(\"sync_log={}\", level));\n    filters.push(format!(\"collab={}\", level));\n    std::env::set_var(\"RUST_LOG\", filters.join(\",\"));\n\n    let subscriber = Subscriber::builder()\n      .with_ansi(true)\n      .with_env_filter(EnvFilter::from_default_env())\n      .finish();\n    subscriber.try_init().unwrap();\n  });\n}\n\n#[cfg(target_arch = \"wasm32\")]\npub fn setup_log() {}\n"
  },
  {
    "path": "libs/client-api-test/src/test_client.rs",
    "content": "use std::borrow::BorrowMut;\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\nuse anyhow::{anyhow, Error};\nuse assert_json_diff::{assert_json_include, assert_json_matches_no_panic, CompareMode, Config};\nuse async_trait::async_trait;\nuse bytes::Bytes;\nuse collab::core::collab::DataSource;\nuse collab::core::collab_state::SyncState;\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse collab::entity::EncodedCollab;\nuse collab::lock::{Mutex, RwLock};\nuse collab::preclude::{ClientID, Collab, Prelim};\nuse collab_database::database::{Database, DatabaseContext};\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::hierarchy_builder::NestedChildViewBuilder;\nuse collab_folder::{Folder, ViewLayout};\nuse collab_user::core::UserAwareness;\nuse mime::Mime;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse shared_entity::dto::publish_dto::PublishViewMetaData;\nuse tokio::time::{timeout, Duration};\nuse tokio_stream::StreamExt;\nuse tracing::trace;\nuse uuid::Uuid;\n\nuse client_api::collab_sync::{SinkConfig, SyncObject, SyncPlugin};\nuse client_api::entity::id::user_awareness_object_id;\nuse client_api::entity::{\n  CompletionStream, CompletionStreamValue, PublishCollabItem, PublishCollabMetadata,\n  QueryWorkspaceMember, QuestionStream, QuestionStreamValue, UpdateCollabWebParams,\n};\nuse client_api::ws::{WSClient, WSClientConfig};\nuse database_entity::dto::{\n  AFCollabEmbedInfo, AFRole, AFUserProfile, AFUserWorkspaceInfo, AFWorkspace,\n  AFWorkspaceInvitationStatus, AFWorkspaceMember, BatchQueryCollabResult, CollabParams,\n  CreateCollabParams, QueryCollab, QueryCollabParams,\n};\nuse shared_entity::dto::ai_dto::CalculateSimilarityParams;\nuse shared_entity::dto::search_dto::SearchDocumentResponseItem;\nuse shared_entity::dto::workspace_dto::{\n  BlobMetadata, CollabResponse, EmbeddedCollabQuery, PublishedDuplicate, WorkspaceMemberChangeset,\n  WorkspaceMemberInvitation, WorkspaceSpaceUsage,\n};\nuse shared_entity::response::AppResponseError;\n\nuse crate::database_util::TestDatabaseCollabService;\nuse crate::user::{generate_unique_registered_user, User};\nuse crate::{\n  load_env, localhost_client_with_device_id, setup_log, JsonAssertable, TestClientConstants,\n};\n\nuse collab::core::collab::CollabOptions;\nuse collab_database::database_trait::CollabRef;\nuse rand::random;\n\npub struct TestClient {\n  pub user: User,\n  pub ws_client: WSClient,\n  pub api_client: client_api::Client,\n  pub collabs: HashMap<Uuid, TestCollab>,\n  pub device_id: String,\n}\npub struct TestCollab {\n  #[allow(dead_code)]\n  pub origin: CollabOrigin,\n  pub collab: Arc<RwLock<Collab>>,\n}\n\nimpl TestCollab {\n  pub async fn encode_collab(&self) -> EncodedCollab {\n    let lock = self.collab.read().await;\n    lock\n      .encode_collab_v1(|_| Ok::<(), anyhow::Error>(()))\n      .unwrap()\n  }\n}\n\nimpl TestClient {\n  pub async fn new(registered_user: User, start_ws_conn: bool) -> Self {\n    load_env();\n    setup_log();\n    let device_id = Uuid::new_v4().to_string();\n    Self::new_with_device_id(&device_id, registered_user, start_ws_conn).await\n  }\n\n  fn random_client_id() -> ClientID {\n    random::<u32>() as ClientID\n  }\n\n  pub async fn client_id(&self, _workspace_id: &Uuid) -> ClientID {\n    Self::random_client_id()\n  }\n\n  pub async fn insert_into<S: Prelim>(&self, object_id: &Uuid, key: &str, value: S) {\n    let mut lock = self.collabs.get(object_id).unwrap().collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.insert(key, value);\n  }\n\n  pub async fn new_with_device_id(\n    device_id: &str,\n    registered_user: User,\n    start_ws_conn: bool,\n  ) -> Self {\n    setup_log();\n    let api_client = localhost_client_with_device_id(device_id);\n    api_client\n      .sign_in_password(&registered_user.email, &registered_user.password)\n      .await\n      .unwrap();\n    let device_id = api_client.device_id.clone();\n\n    // Connect to server via websocket\n    let ws_client = WSClient::new(\n      WSClientConfig {\n        buffer_capacity: 100,\n        ping_per_secs: 6,\n        retry_connect_per_pings: 5,\n      },\n      api_client.clone(),\n      api_client.clone(),\n    );\n    if start_ws_conn {\n      ws_client.connect().await.unwrap();\n    }\n    Self {\n      user: registered_user,\n      ws_client,\n      api_client,\n      collabs: Default::default(),\n      device_id,\n    }\n  }\n\n  pub async fn new_user() -> Self {\n    let registered_user = generate_unique_registered_user().await;\n    let this = Self::new(registered_user, true).await;\n    let uid = this.uid().await;\n    trace!(\"🤖New user created: {}\", uid);\n    this\n  }\n\n  pub async fn new_user_without_ws_conn() -> Self {\n    let registered_user = generate_unique_registered_user().await;\n    Self::new(registered_user, false).await\n  }\n\n  pub fn disable_receive_message(&mut self) {\n    self.ws_client.disable_receive_message();\n  }\n\n  pub fn enable_receive_message(&mut self) {\n    self.ws_client.enable_receive_message();\n  }\n\n  pub async fn insert_view_to_general_space(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &str,\n    view_name: &str,\n    view_layout: ViewLayout,\n    uid: i64,\n  ) {\n    let mut folder = self.get_folder(*workspace_id).await;\n    let general_space_id = folder\n      .get_view(&workspace_id.to_string(), uid)\n      .unwrap()\n      .children\n      .first()\n      .unwrap()\n      .clone();\n    let view = NestedChildViewBuilder::new(self.uid().await, general_space_id.id.clone())\n      .with_view_id(view_id.to_string())\n      .with_name(view_name)\n      .with_layout(view_layout)\n      .build()\n      .view;\n    {\n      let mut txn = folder.collab.transact_mut();\n      folder.body.views.insert(&mut txn, view, None, uid);\n    }\n    let folder_collab_type = CollabType::Folder;\n    self\n      .api_client\n      .update_web_collab(\n        workspace_id,\n        workspace_id,\n        UpdateCollabWebParams {\n          doc_state: folder\n            .encode_collab_v1(|c| folder_collab_type.validate_require_data(c))\n            .unwrap()\n            .doc_state\n            .to_vec(),\n          collab_type: CollabType::Folder,\n        },\n      )\n      .await\n      .unwrap();\n  }\n\n  pub async fn get_folder(&self, workspace_id: Uuid) -> Folder {\n    let uid = self.uid().await;\n    let folder_collab = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_id,\n        CollabType::Folder,\n        workspace_id,\n      ))\n      .await\n      .unwrap()\n      .encode_collab;\n    Folder::from_collab_doc_state(\n      CollabOrigin::Client(CollabClient::new(uid, self.device_id.clone())),\n      folder_collab.into(),\n      &workspace_id.to_string(),\n      Self::random_client_id(),\n    )\n    .unwrap()\n  }\n\n  pub async fn get_database(&self, workspace_id: Uuid, database_id: &str) -> Database {\n    let service = Arc::new(TestDatabaseCollabService::new(\n      self.api_client.clone(),\n      workspace_id,\n      Self::random_client_id(),\n    ));\n    let context = DatabaseContext::new(service.clone(), service);\n    Database::open(database_id, context).await.unwrap()\n  }\n\n  pub async fn get_document(&self, workspace_id: Uuid, document_id: Uuid) -> Document {\n    let collab = self\n      .get_collab_to_collab(workspace_id, document_id, CollabType::Document)\n      .await\n      .unwrap();\n    Document::open(collab).unwrap()\n  }\n\n  pub async fn get_workspace_database(&self, workspace_id: Uuid) -> WorkspaceDatabase {\n    let workspaces = self.api_client.get_workspaces().await.unwrap();\n    let workspace_database_id = workspaces\n      .iter()\n      .find(|w| w.workspace_id == workspace_id)\n      .unwrap()\n      .database_storage_id;\n\n    let collab = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_database_id,\n        CollabType::WorkspaceDatabase,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n\n    WorkspaceDatabase::from_collab_doc_state(\n      &workspace_database_id.to_string(),\n      CollabOrigin::Empty,\n      collab.encode_collab.into(),\n      Self::random_client_id(),\n    )\n    .unwrap()\n  }\n\n  pub async fn get_connect_users(&self, object_id: &Uuid) -> Vec<i64> {\n    #[derive(Deserialize)]\n    struct UserId {\n      pub uid: i64,\n    }\n\n    let lock = self.collabs.get(object_id).unwrap().collab.read().await;\n    lock\n      .get_awareness()\n      .iter()\n      .flat_map(|(_a, client)| match &client.data {\n        None => None,\n        Some(json) => {\n          let user: UserId = serde_json::from_str(json).unwrap();\n          Some(user.uid)\n        },\n      })\n      .collect()\n  }\n\n  pub async fn clean_awareness_state(&self, object_id: &Uuid) {\n    let test_collab = self.collabs.get(object_id).unwrap();\n    let mut lock = test_collab.collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.clean_awareness_state();\n  }\n\n  pub async fn emit_awareness_state(&self, object_id: &Uuid) {\n    let test_collab = self.collabs.get(object_id).unwrap();\n    let mut lock = test_collab.collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.emit_awareness_state();\n    tracing::trace!(\n      \"emit awareness state for collab: {} (client id: {}): {:#?}\",\n      object_id,\n      collab.doc().client_id(),\n      collab.get_awareness().update().unwrap()\n    );\n  }\n\n  pub async fn user_with_new_device(registered_user: User) -> Self {\n    Self::new(registered_user, true).await\n  }\n\n  pub async fn get_user_workspace_info(&self) -> AFUserWorkspaceInfo {\n    self.api_client.get_user_workspace_info().await.unwrap()\n  }\n\n  pub async fn open_workspace(&self, workspace_id: &Uuid) -> AFWorkspace {\n    self.api_client.open_workspace(workspace_id).await.unwrap()\n  }\n\n  pub async fn get_user_folder(&self) -> Folder {\n    let workspace_id = self.workspace_id().await;\n    let data = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_id,\n        CollabType::Folder,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n\n    Folder::from_collab_doc_state(\n      CollabOrigin::Empty,\n      data.encode_collab.into(),\n      &workspace_id.to_string(),\n      Self::random_client_id(),\n    )\n    .unwrap()\n  }\n\n  pub async fn get_workspace_database_collab(&self, workspace_id: Uuid) -> Collab {\n    let db_storage_id = self.open_workspace(&workspace_id).await.database_storage_id;\n    let collab_resp = self\n      .get_collab(workspace_id, db_storage_id, CollabType::WorkspaceDatabase)\n      .await\n      .unwrap();\n    let options = CollabOptions::new(db_storage_id.to_string(), Self::random_client_id())\n      .with_data_source(collab_resp.encode_collab.into());\n    Collab::new_with_options(CollabOrigin::Server, options).unwrap()\n  }\n\n  pub async fn create_document_collab(&self, workspace_id: Uuid, object_id: Uuid) -> Document {\n    let collab_resp = self\n      .get_collab(workspace_id, object_id, CollabType::Document)\n      .await\n      .unwrap();\n    let options = CollabOptions::new(object_id.to_string(), Self::random_client_id())\n      .with_data_source(collab_resp.encode_collab.into());\n    let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n    Document::open(collab).unwrap()\n  }\n\n  pub async fn get_db_collab_from_view(&mut self, workspace_id: Uuid, view_id: &Uuid) -> Collab {\n    let ws_db_collab = self.get_workspace_database_collab(workspace_id).await;\n    let ws_db_body = WorkspaceDatabase::open(ws_db_collab).unwrap();\n    let db_id = ws_db_body\n      .get_all_database_meta()\n      .into_iter()\n      .find(|db_meta| db_meta.linked_views.contains(&view_id.to_string()))\n      .unwrap()\n      .database_id\n      .parse::<Uuid>()\n      .unwrap();\n    let db_collab_collab_resp = self\n      .get_collab(workspace_id, db_id, CollabType::Database)\n      .await\n      .unwrap();\n    let options = CollabOptions::new(db_id.to_string(), Self::random_client_id())\n      .with_data_source(db_collab_collab_resp.encode_collab.into());\n    Collab::new_with_options(CollabOrigin::Server, options).unwrap()\n  }\n\n  pub async fn get_user_awareness(&self) -> UserAwareness {\n    let workspace_id = self.workspace_id().await;\n    let profile = self.get_user_profile().await;\n    let awareness_object_id = user_awareness_object_id(&profile.uuid, &workspace_id);\n    let data = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        awareness_object_id,\n        CollabType::UserAwareness,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n    let options = CollabOptions::new(awareness_object_id.to_string(), Self::random_client_id())\n      .with_data_source(DataSource::DocStateV1(\n        data.encode_collab.doc_state.to_vec(),\n      ));\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options).unwrap();\n\n    UserAwareness::open(collab, None).unwrap()\n  }\n\n  pub async fn try_update_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n    role: AFRole,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n    self\n      .api_client\n      .update_workspace_member(\n        workspace_id,\n        WorkspaceMemberChangeset::new(email).with_role(role),\n      )\n      .await\n  }\n\n  pub async fn invite_and_accepted_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n    role: AFRole,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n\n    self\n      .api_client\n      .invite_workspace_members(\n        workspace_id,\n        vec![WorkspaceMemberInvitation {\n          email,\n          role,\n          skip_email_send: true,\n          ..Default::default()\n        }],\n      )\n      .await?;\n\n    let invitations = other_client\n      .api_client\n      .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Pending))\n      .await\n      .unwrap();\n\n    let target_invitation = invitations\n      .iter()\n      .find(|inv| &inv.workspace_id == workspace_id)\n      .unwrap();\n\n    other_client\n      .api_client\n      .accept_workspace_invitation(target_invitation.invite_id.to_string().as_str())\n      .await\n      .unwrap();\n\n    Ok(())\n  }\n\n  pub async fn try_remove_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n    self\n      .api_client\n      .remove_workspace_members(workspace_id, vec![email])\n      .await\n  }\n\n  pub async fn get_workspace_members(&self, workspace_id: &Uuid) -> Vec<AFWorkspaceMember> {\n    self\n      .api_client\n      .get_workspace_members(workspace_id)\n      .await\n      .unwrap()\n  }\n\n  pub async fn try_get_workspace_members(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<AFWorkspaceMember>, AppResponseError> {\n    self.api_client.get_workspace_members(workspace_id).await\n  }\n\n  pub async fn get_workspace_member(&self, workspace_id: Uuid, user_id: i64) -> AFWorkspaceMember {\n    let params = QueryWorkspaceMember {\n      workspace_id,\n      uid: user_id,\n    };\n    self.api_client.get_workspace_member(params).await.unwrap()\n  }\n\n  pub async fn try_get_workspace_member(\n    &self,\n    workspace_id: Uuid,\n    user_id: i64,\n  ) -> Result<AFWorkspaceMember, AppResponseError> {\n    let params = QueryWorkspaceMember {\n      workspace_id,\n      uid: user_id,\n    };\n\n    self.api_client.get_workspace_member(params).await\n  }\n\n  pub async fn wait_object_sync_complete(&self, object_id: &Uuid) -> Result<(), Error> {\n    self\n      .wait_object_sync_complete_with_secs(object_id, 60)\n      .await\n  }\n\n  pub async fn wait_object_sync_complete_with_secs(\n    &self,\n    object_id: &Uuid,\n    secs: u64,\n  ) -> Result<(), Error> {\n    let mut sync_state = {\n      let lock = self.collabs.get(object_id).unwrap().collab.read().await;\n      lock.subscribe_sync_state()\n    };\n\n    let duration = Duration::from_secs(secs);\n    while let Ok(Some(state)) = timeout(duration, sync_state.next()).await {\n      if state == SyncState::SyncFinished {\n        return Ok(());\n      }\n    }\n\n    Err(anyhow!(\n      \"Timeout or SyncState stream ended before reaching SyncFinished\"\n    ))\n  }\n\n  #[allow(dead_code)]\n  pub async fn get_blob_metadata(&self, workspace_id: &Uuid, file_id: &str) -> BlobMetadata {\n    let url = self.api_client.get_blob_url(workspace_id, file_id);\n    self.api_client.get_blob_metadata(&url).await.unwrap()\n  }\n\n  pub async fn upload_blob<T: Into<Bytes>>(&self, file_id: &str, data: T, mime: &Mime) {\n    let workspace_id = self.workspace_id().await;\n    let url = self.api_client.get_blob_url(&workspace_id, file_id);\n    self.api_client.put_blob(&url, data, mime).await.unwrap()\n  }\n\n  pub async fn delete_file(&self, file_id: &str) {\n    let workspace_id = self.workspace_id().await;\n    let url = self.api_client.get_blob_url(&workspace_id, file_id);\n    self.api_client.delete_blob(&url).await.unwrap();\n  }\n\n  pub async fn get_workspace_usage(&self) -> WorkspaceSpaceUsage {\n    let workspace_id = self.workspace_id().await;\n    self\n      .api_client\n      .get_workspace_usage(&workspace_id)\n      .await\n      .unwrap()\n  }\n\n  pub async fn workspace_id(&self) -> Uuid {\n    self\n      .api_client\n      .get_workspaces()\n      .await\n      .unwrap()\n      .first()\n      .unwrap()\n      .workspace_id\n  }\n\n  pub async fn email(&self) -> String {\n    self.api_client.get_profile().await.unwrap().email.unwrap()\n  }\n\n  pub async fn uid(&self) -> i64 {\n    self.api_client.get_profile().await.unwrap().uid\n  }\n\n  pub async fn get_user_profile(&self) -> AFUserProfile {\n    self.api_client.get_profile().await.unwrap()\n  }\n\n  pub async fn wait_until_all_embedding(\n    &self,\n    workspace_id: &Uuid,\n    query: Vec<EmbeddedCollabQuery>,\n  ) -> Result<Vec<AFCollabEmbedInfo>, AppResponseError> {\n    let timeout_duration = Duration::from_secs(60);\n    let poll_interval = Duration::from_millis(2000);\n    let poll_fut = async {\n      loop {\n        match self\n          .api_client\n          .batch_get_collab_embed_info(workspace_id, query.clone())\n          .await\n        {\n          Ok(items) if items.len() >= query.len() => return Ok::<_, Error>(items),\n          _ => tokio::time::sleep(poll_interval).await,\n        }\n      }\n    };\n\n    // Enforce timeout\n    match timeout(timeout_duration, poll_fut).await {\n      Ok(Ok(items)) => Ok(items),\n      Ok(Err(e)) => Err(e.into()),\n      Err(_) => Err(anyhow!(\"Test failed: Timeout after 30 seconds. {:?}\", query).into()),\n    }\n  }\n\n  pub async fn wait_until_get_embedding(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    timeout(Duration::from_secs(30), async {\n      while self\n        .api_client\n        .get_collab_embed_info(workspace_id, object_id)\n        .await\n        .is_err()\n      {\n        tokio::time::sleep(Duration::from_millis(2000)).await;\n      }\n      self\n        .api_client\n        .get_collab_embed_info(workspace_id, object_id)\n        .await\n    })\n    .await\n    .unwrap()?;\n    Ok(())\n  }\n\n  pub async fn wait_unit_get_search_result(\n    &self,\n    workspace_id: &Uuid,\n    query: &str,\n    limit: u32,\n    preview: u32,\n    score_limit: Option<f32>,\n  ) -> Result<Vec<SearchDocumentResponseItem>, AppResponseError> {\n    let res = timeout(Duration::from_secs(30), async {\n      loop {\n        let response = self\n          .api_client\n          .search_documents(workspace_id, query, limit, preview, score_limit)\n          .await\n          .unwrap();\n\n        if response.is_empty() {\n          tokio::time::sleep(Duration::from_millis(1500)).await;\n          continue;\n        } else {\n          return response;\n        }\n      }\n    })\n    .await\n    .unwrap();\n    Ok(res)\n  }\n\n  pub async fn assert_similarity(\n    &self,\n    workspace_id: &Uuid,\n    input: &str,\n    expected: &str,\n    score: f64,\n    use_embedding: bool,\n  ) {\n    let params = CalculateSimilarityParams {\n      workspace_id: *workspace_id,\n      input: input.to_string(),\n      expected: expected.to_string(),\n      use_embedding,\n    };\n    let resp = self.api_client.calculate_similarity(params).await.unwrap();\n    assert!(\n      resp.score > score,\n      \"Similarity score is too low: {}.\\nexpected: {},\\ninput: {},\\nexpected:{}\",\n      resp.score,\n      score,\n      input,\n      expected\n    );\n  }\n\n  pub async fn create_collab_list(\n    &mut self,\n    workspace_id: &Uuid,\n    params: Vec<CollabParams>,\n  ) -> Result<(), AppResponseError> {\n    self\n      .api_client\n      .create_collab_list(workspace_id, params)\n      .await\n  }\n\n  pub async fn get_collab(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) -> Result<CollabResponse, AppResponseError> {\n    self\n      .api_client\n      .get_collab(QueryCollabParams {\n        workspace_id,\n        inner: QueryCollab {\n          object_id,\n          collab_type,\n        },\n      })\n      .await\n  }\n\n  pub async fn get_collab_to_collab(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) -> Result<Collab, AppResponseError> {\n    let resp = self\n      .get_collab(workspace_id, object_id, collab_type)\n      .await?;\n    let options = CollabOptions::new(object_id.to_string(), Self::random_client_id())\n      .with_data_source(resp.encode_collab.into());\n    let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n    Ok(collab)\n  }\n\n  pub async fn batch_get_collab(\n    &mut self,\n    workspace_id: &Uuid,\n    params: Vec<QueryCollab>,\n  ) -> Result<BatchQueryCollabResult, AppResponseError> {\n    self.api_client.batch_get_collab(workspace_id, params).await\n  }\n\n  #[allow(clippy::await_holding_lock)]\n  pub async fn create_and_edit_collab(\n    &mut self,\n    workspace_id: Uuid,\n    collab_type: CollabType,\n  ) -> Uuid {\n    let object_id = Uuid::new_v4();\n    self\n      .create_and_edit_collab_with_data(object_id, workspace_id, collab_type, None, true)\n      .await;\n    object_id\n  }\n\n  #[allow(unused_variables)]\n  pub async fn create_and_edit_collab_with_data(\n    &mut self,\n    object_id: Uuid,\n    workspace_id: Uuid,\n    collab_type: CollabType,\n    encoded_collab_v1: Option<EncodedCollab>,\n    sync: bool,\n  ) {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let mut collab = match encoded_collab_v1 {\n      None => {\n        let options = CollabOptions::new(object_id.to_string(), Self::random_client_id());\n        Collab::new_with_options(origin.clone(), options).unwrap()\n      },\n      Some(data) => {\n        let options = CollabOptions::new(object_id.to_string(), Self::random_client_id())\n          .with_data_source(data.into());\n        Collab::new_with_options(origin.clone(), options).unwrap()\n      },\n    };\n\n    collab.emit_awareness_state();\n    tracing::trace!(\n      \"emit awareness state for collab: {} (client id: {}) on collab created: {:#?}\",\n      object_id,\n      collab.doc().client_id(),\n      collab.get_awareness().update().unwrap()\n    );\n    let encoded_collab_v1 = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .unwrap()\n      .encode_to_bytes()\n      .unwrap();\n\n    self\n      .api_client\n      .create_collab(CreateCollabParams {\n        object_id,\n        encoded_collab_v1,\n        collab_type,\n        workspace_id,\n      })\n      .await\n      .unwrap();\n\n    let collab = Arc::new(RwLock::from(collab));\n    let collab_ref = collab.clone() as CollabRef;\n    {\n      let handler = self\n        .ws_client\n        .subscribe_collab(object_id.to_string())\n        .unwrap();\n      let (sink, stream) = (handler.sink(), handler.stream());\n      let ws_connect_state = self.ws_client.subscribe_connect_state();\n      let object = SyncObject::new(object_id, workspace_id, collab_type, &self.device_id);\n      let sync_plugin = SyncPlugin::new(\n        origin.clone(),\n        object,\n        Arc::downgrade(&collab_ref),\n        sink,\n        SinkConfig::default(),\n        stream,\n        Some(handler),\n        ws_connect_state,\n        Some(Duration::from_secs(10)),\n      );\n      let lock = collab.read().await;\n      lock.add_plugin(Box::new(sync_plugin));\n    }\n    {\n      let mut lock = collab.write().await;\n      let collab = (*lock).borrow_mut();\n      collab.initialize();\n    }\n    let test_collab = TestCollab { origin, collab };\n    self.collabs.insert(object_id, test_collab);\n    if sync {\n      self.wait_object_sync_complete(&object_id).await.unwrap();\n    }\n  }\n\n  pub async fn open_workspace_collab(&mut self, workspace_id: Uuid) {\n    self\n      .open_collab(workspace_id, workspace_id, CollabType::Unknown)\n      .await;\n  }\n\n  #[allow(clippy::await_holding_lock)]\n  pub async fn open_collab(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) {\n    self\n      .open_collab_with_doc_state(workspace_id, object_id, collab_type, vec![])\n      .await\n  }\n\n  #[allow(unused_variables)]\n  pub async fn open_collab_with_doc_state(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    doc_state: Vec<u8>,\n  ) {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let options = CollabOptions::new(object_id.to_string(), Self::random_client_id())\n      .with_data_source(DataSource::DocStateV1(doc_state));\n    let mut collab = Collab::new_with_options(origin.clone(), options).unwrap();\n    collab.emit_awareness_state();\n    tracing::trace!(\n      \"emit awareness state for collab: {} (client id: {}) on collab open: {:#?}\",\n      object_id,\n      collab.doc().client_id(),\n      collab.get_awareness().update().unwrap()\n    );\n    let collab = Arc::new(RwLock::from(collab));\n    let collab_ref = collab.clone() as CollabRef;\n\n    {\n      let handler = self\n        .ws_client\n        .subscribe_collab(object_id.to_string())\n        .unwrap();\n      let (sink, stream) = (handler.sink(), handler.stream());\n      let ws_connect_state = self.ws_client.subscribe_connect_state();\n      let object = SyncObject::new(object_id, workspace_id, collab_type, &self.device_id);\n      let sync_plugin = SyncPlugin::new(\n        origin.clone(),\n        object,\n        Arc::downgrade(&collab_ref),\n        sink,\n        SinkConfig::default(),\n        stream,\n        Some(handler),\n        ws_connect_state,\n        Some(Duration::from_secs(10)),\n      );\n\n      let lock = collab.read().await;\n      lock.add_plugin(Box::new(sync_plugin));\n    }\n    {\n      let mut lock = collab.write().await;\n      let collab = (*lock).borrow_mut();\n      collab.initialize();\n    }\n    let test_collab = TestCollab { origin, collab };\n    self.collabs.insert(object_id, test_collab);\n  }\n\n  #[allow(unused_variables)]\n  pub async fn create_collab_with_data(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    encoded_collab_v1: EncodedCollab,\n  ) -> Result<(), AppResponseError> {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let options = CollabOptions::new(object_id.to_string(), Self::random_client_id())\n      .with_data_source(encoded_collab_v1.into());\n    let collab = Collab::new_with_options(origin.clone(), options).unwrap();\n\n    let encoded_collab_v1 = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .unwrap()\n      .encode_to_bytes()\n      .unwrap();\n\n    self\n      .api_client\n      .create_collab(CreateCollabParams {\n        object_id,\n        encoded_collab_v1,\n        collab_type,\n        workspace_id,\n      })\n      .await\n  }\n\n  #[cfg(not(target_arch = \"wasm32\"))]\n  pub async fn post_realtime_binary(&self, message: Vec<u8>) -> Result<(), AppResponseError> {\n    let message = client_websocket::Message::binary(message);\n    self\n      .api_client\n      .post_realtime_msg(&self.device_id, message)\n      .await\n  }\n\n  pub async fn disconnect(&self) {\n    self.ws_client.disconnect().await;\n  }\n\n  pub async fn reconnect(&self) {\n    self.ws_client.connect().await.unwrap();\n  }\n\n  pub async fn get_edit_collab_json(&self, object_id: &Uuid) -> Value {\n    let lock = self.collabs.get(object_id).unwrap().collab.read().await;\n    lock.to_json_value()\n  }\n\n  /// data: [(view_id, meta_json, blob_hex)]\n  pub async fn publish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    data: Vec<(Uuid, &str, &str)>,\n    comments_enabled: bool,\n    duplicate_enabled: bool,\n  ) {\n    let pub_items = data\n      .into_iter()\n      .map(|(view_id, meta_json, blob_hex)| {\n        let meta: PublishViewMetaData = serde_json::from_str(meta_json).unwrap();\n        let blob = hex::decode(blob_hex).unwrap();\n        PublishCollabItem {\n          meta: PublishCollabMetadata {\n            view_id,\n            publish_name: uuid::Uuid::new_v4().to_string(),\n            metadata: meta,\n          },\n          data: blob,\n          comments_enabled,\n          duplicate_enabled,\n        }\n      })\n      .collect();\n\n    self\n      .api_client\n      .publish_collabs(workspace_id, pub_items)\n      .await\n      .unwrap();\n  }\n\n  pub async fn duplicate_published_to_workspace(\n    &self,\n    dest_workspace_id: Uuid,\n    src_view_id: Uuid,\n    dest_view_id: Uuid,\n  ) {\n    self\n      .api_client\n      .duplicate_published_to_workspace(\n        dest_workspace_id,\n        &PublishedDuplicate {\n          published_view_id: src_view_id,\n          dest_view_id,\n        },\n      )\n      .await\n      .unwrap();\n\n    // wait a while for folder collab to be synced\n    tokio::time::sleep(Duration::from_secs(1)).await;\n  }\n}\n\npub async fn assert_client_collab_value(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  expected: Value,\n) -> Result<(), Error> {\n  let test_collab = client\n    .collabs\n    .get(object_id)\n    .ok_or_else(|| anyhow!(\"Collab not found for object_id: {}\", object_id))?;\n\n  let config = crate::test_client_config::AssertionConfig {\n    timeout: Duration::from_secs(TestClientConstants::SYNC_TIMEOUT_SECS),\n    ..Default::default()\n  };\n\n  test_collab.assert_json_eventually(expected, config).await\n}\n\n#[async_trait]\nimpl JsonAssertable for TestCollab {\n  async fn get_json(&self) -> Result<Value, Error> {\n    let lock = self.collab.read().await;\n    Ok(lock.to_json_value())\n  }\n}\n\npub async fn assert_server_collab(\n  workspace_id: Uuid,\n  client: &mut client_api::Client,\n  object_id: Uuid,\n  collab_type: &CollabType,\n  timeout_secs: u64,\n  expected: Value,\n) -> Result<(), Error> {\n  let duration = Duration::from_secs(timeout_secs);\n  let collab_type = *collab_type;\n  let final_json = Arc::new(Mutex::from(json!({})));\n\n  // Use tokio::time::timeout to apply a timeout to the entire operation\n  let cloned_final_json = final_json.clone();\n  let operation = async {\n    loop {\n      let result = client\n        .get_collab(QueryCollabParams::new(object_id, collab_type, workspace_id))\n        .await;\n\n      match &result {\n        Ok(data) => {\n          let options = CollabOptions::new(object_id.to_string(), TestClient::random_client_id())\n            .with_data_source(DataSource::DocStateV1(\n              data.encode_collab.doc_state.clone().to_vec(),\n            ));\n          let json = Collab::new_with_options(CollabOrigin::Empty, options)\n            .unwrap()\n            .to_json_value();\n\n          *cloned_final_json.lock().await = json.clone();\n          if assert_json_matches_no_panic(&json, &expected, Config::new(CompareMode::Inclusive))\n            .is_ok()\n          {\n            return;\n          }\n        },\n        Err(e) => {\n          // Instead of panicking immediately, log or handle the error and continue the loop\n          // until the timeout is reached.\n          eprintln!(\"Query collab failed: {}\", e);\n        },\n      }\n\n      // Sleep before retrying. Adjust the sleep duration as needed.\n      tokio::time::sleep(Duration::from_millis(1000)).await;\n    }\n  };\n\n  if timeout(duration, operation).await.is_err() {\n    eprintln!(\"json:{}\\nexpected:{}\", final_json.lock().await, expected);\n    return Err(anyhow!(\"time out for the action\"));\n  }\n  Ok(())\n}\n\npub async fn assert_client_collab_within_secs(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  key: &str,\n  expected: Value,\n  secs: u64,\n) {\n  let mut retry_count = 0;\n  loop {\n    tokio::select! {\n       _ = tokio::time::sleep(Duration::from_secs(secs)) => {\n         panic!(\"timeout\");\n       },\n       json = async {\n        let lock = client\n          .collabs\n          .get_mut(object_id)\n          .unwrap()\n          .collab\n          .read()\n          .await;\n        lock.to_json_value()\n      } => {\n        retry_count += 1;\n        if retry_count > 60 {\n            assert_eq!(json[key], expected[key], \"object_id: {}\", object_id);\n            break;\n          }\n        if json[key] == expected[key] {\n          break;\n        }\n        tokio::time::sleep(Duration::from_millis(1000)).await;\n      }\n    }\n  }\n}\n\npub async fn assert_client_collab_include_value(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  expected: Value,\n) -> Result<(), Error> {\n  let secs = 60;\n  let mut retry_count = 0;\n  loop {\n    tokio::select! {\n       _ = tokio::time::sleep(Duration::from_secs(secs)) => {\n        return Err(anyhow!(\"timeout\"));\n       },\n       json = async {\n        let lock = client\n          .collabs\n          .get_mut(object_id)\n          .unwrap()\n          .collab\n          .read()\n          .await;\n        lock.to_json_value()\n      } => {\n        retry_count += 1;\n        if retry_count > 30 {\n          assert_json_include!(actual: json, expected: expected);\n          return Ok(());\n          }\n        if assert_json_matches_no_panic(&json, &expected, Config::new(CompareMode::Inclusive)).is_ok() {\n          return Ok(());\n        }\n        tokio::time::sleep(Duration::from_secs(1)).await;\n      }\n    }\n  }\n}\n\npub async fn collect_answer(mut stream: QuestionStream) -> String {\n  let mut answer = String::new();\n  while let Some(value) = stream.next().await {\n    match value.unwrap() {\n      QuestionStreamValue::Answer { value } => {\n        answer.push_str(&value);\n      },\n      QuestionStreamValue::Metadata { .. } => {},\n      QuestionStreamValue::FollowUp { .. } => {},\n      QuestionStreamValue::SuggestedQuestion { .. } => {},\n    }\n  }\n  answer\n}\n\npub async fn collect_completion_v2(mut stream: CompletionStream) -> (String, String) {\n  let mut answer = String::new();\n  let mut comment = String::new();\n  while let Some(value) = stream.next().await {\n    match value.unwrap() {\n      CompletionStreamValue::Answer { value } => {\n        answer.push_str(&value);\n      },\n      CompletionStreamValue::Comment { value } => {\n        comment.push_str(&value);\n      },\n    }\n  }\n  (answer, comment)\n}\n"
  },
  {
    "path": "libs/client-api-test/src/test_client_config.rs",
    "content": "use assert_json_diff::CompareMode;\nuse std::time::Duration;\n\n/// Configuration constants for the test client\npub struct TestClientConstants;\n\nimpl TestClientConstants {\n  pub const DEFAULT_TIMEOUT_SECS: u64 = 30;\n  pub const DEFAULT_POLL_INTERVAL_MS: u64 = 1000;\n  pub const MAX_RETRY_COUNT: u32 = 10;\n  pub const SYNC_TIMEOUT_SECS: u64 = 60;\n  pub const EMBEDDING_TIMEOUT_SECS: u64 = 30;\n  pub const EMBEDDING_POLL_INTERVAL_MS: u64 = 2000;\n  pub const SEARCH_POLL_INTERVAL_MS: u64 = 1500;\n  pub const SNAPSHOT_POLL_INTERVAL_SECS: u64 = 5;\n}\n\n/// Configuration for retry operations\n#[derive(Clone, Debug)]\npub struct RetryConfig {\n  pub timeout: Duration,\n  pub poll_interval: Duration,\n  pub max_retries: u32,\n}\n\nimpl Default for RetryConfig {\n  fn default() -> Self {\n    Self {\n      timeout: Duration::from_secs(TestClientConstants::DEFAULT_TIMEOUT_SECS),\n      poll_interval: Duration::from_millis(TestClientConstants::DEFAULT_POLL_INTERVAL_MS),\n      max_retries: TestClientConstants::MAX_RETRY_COUNT,\n    }\n  }\n}\n\nimpl RetryConfig {\n  pub fn for_embedding() -> Self {\n    Self {\n      timeout: Duration::from_secs(TestClientConstants::EMBEDDING_TIMEOUT_SECS),\n      poll_interval: Duration::from_millis(TestClientConstants::EMBEDDING_POLL_INTERVAL_MS),\n      max_retries: 15,\n    }\n  }\n\n  pub fn for_sync() -> Self {\n    Self {\n      timeout: Duration::from_secs(TestClientConstants::SYNC_TIMEOUT_SECS),\n      poll_interval: Duration::from_millis(TestClientConstants::DEFAULT_POLL_INTERVAL_MS),\n      max_retries: 60,\n    }\n  }\n\n  pub fn for_search() -> Self {\n    Self {\n      timeout: Duration::from_secs(TestClientConstants::DEFAULT_TIMEOUT_SECS),\n      poll_interval: Duration::from_millis(TestClientConstants::SEARCH_POLL_INTERVAL_MS),\n      max_retries: 20,\n    }\n  }\n}\n\n/// Configuration for JSON assertions\n#[derive(Clone, Debug)]\npub struct AssertionConfig {\n  pub timeout: Duration,\n  pub retry_interval: Duration,\n  pub comparison_mode: CompareMode,\n  pub max_retries: u32,\n}\n\nimpl Default for AssertionConfig {\n  fn default() -> Self {\n    Self {\n      timeout: Duration::from_secs(TestClientConstants::DEFAULT_TIMEOUT_SECS),\n      retry_interval: Duration::from_millis(TestClientConstants::DEFAULT_POLL_INTERVAL_MS),\n      comparison_mode: CompareMode::Inclusive,\n      max_retries: TestClientConstants::MAX_RETRY_COUNT,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-api-test/src/test_client_v2.rs",
    "content": "use std::borrow::BorrowMut;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse anyhow::{anyhow, Error};\nuse async_trait::async_trait;\nuse bytes::Bytes;\nuse mime::Mime;\nuse serde::Deserialize;\nuse serde_json::Value;\nuse tempfile::TempDir;\nuse tokio_stream::StreamExt;\nuse tracing::trace;\nuse uuid::Uuid;\n\n// Client API imports\nuse client_api::entity::id::user_awareness_object_id;\nuse client_api::entity::{\n  CompletionStream, CompletionStreamValue, PublishCollabItem, PublishCollabMetadata,\n  QueryWorkspaceMember, QuestionStream, QuestionStreamValue, UpdateCollabWebParams,\n  WorkspaceNotification,\n};\nuse client_api::v2::WorkspaceController;\n\n// Collab imports\nuse collab::core::collab::{CollabOptions, DataSource};\nuse collab::core::collab_state::SyncState;\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse collab::entity::EncodedCollab;\nuse collab::lock::RwLock;\nuse collab::preclude::{ClientID, Collab, Prelim};\n\n// Collab feature imports\nuse collab_database::database::{Database, DatabaseContext};\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::hierarchy_builder::NestedChildViewBuilder;\nuse collab_folder::{Folder, ViewLayout};\nuse collab_user::core::UserAwareness;\n\n// Database entity imports\nuse database_entity::dto::{\n  AFCollabEmbedInfo, AFRole, AFUserProfile, AFUserWorkspaceInfo, AFWorkspace,\n  AFWorkspaceInvitationStatus, AFWorkspaceMember, BatchQueryCollabResult, CollabParams,\n  CreateCollabParams, QueryCollab, QueryCollabParams,\n};\n\n// Shared entity imports\nuse shared_entity::dto::ai_dto::CalculateSimilarityParams;\nuse shared_entity::dto::publish_dto::PublishViewMetaData;\nuse shared_entity::dto::search_dto::SearchDocumentResponseItem;\nuse shared_entity::dto::workspace_dto::{\n  BlobMetadata, CollabResponse, EmbeddedCollabQuery, PublishedDuplicate, WorkspaceMemberChangeset,\n  WorkspaceMemberInvitation, WorkspaceSpaceUsage,\n};\nuse shared_entity::response::AppResponseError;\n\n// Internal imports\nuse crate::database_util::TestDatabaseCollabService;\nuse crate::user::{generate_unique_registered_user, User};\nuse crate::{assertion_utils, load_env, localhost_client_with_device_id, setup_log};\n\n// New module imports\nuse crate::assertion_utils::{assert_server_collab_eventually, JsonAssertable};\nuse crate::async_utils::retry_api_with_constant_interval;\nuse crate::test_client_config::{RetryConfig, TestClientConstants};\nuse crate::workspace_ops::WorkspaceManager;\n\npub type CollabRef = Arc<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>;\n\npub struct TestClient {\n  pub user: User,\n  pub api_client: client_api::Client,\n  pub collabs: HashMap<Uuid, TestCollab>,\n  pub device_id: String,\n  workspace_manager: WorkspaceManager,\n}\n\nimpl TestClient {\n  ///\n  /// # Arguments\n  /// * `registered_user` - The user to sign in with\n  /// * `start_ws_conn` - Whether to start websocket connections immediately\n  pub async fn new(registered_user: User, start_ws_conn: bool) -> Self {\n    load_env();\n    setup_log();\n    let device_id = Uuid::new_v4().to_string();\n    Self::new_with_device_id(&device_id, registered_user, start_ws_conn).await\n  }\n\n  /// Creates a new test client with a specific device ID\n  pub async fn new_with_device_id(\n    device_id: &str,\n    registered_user: User,\n    start_ws_conn: bool,\n  ) -> Self {\n    setup_log();\n    let temp_dir = Arc::new(TempDir::new().unwrap());\n    let api_client = localhost_client_with_device_id(device_id);\n\n    // Sign in the user\n    api_client\n      .sign_in_password(&registered_user.email, &registered_user.password)\n      .await\n      .unwrap();\n\n    let uid = api_client.get_profile().await.unwrap().uid;\n    let workspace_id = api_client\n      .get_workspaces()\n      .await\n      .unwrap()\n      .first()\n      .unwrap()\n      .workspace_id;\n    let device_id = api_client.device_id.clone();\n\n    let workspace_manager = WorkspaceManager::new(device_id.clone(), temp_dir.clone());\n    let client = Self {\n      user: registered_user,\n      api_client,\n      collabs: HashMap::new(),\n      device_id,\n      workspace_manager,\n    };\n\n    // Set up the initial workspace\n    if start_ws_conn {\n      let access_token = client.api_client.access_token().ok();\n      if let Err(err) = client\n        .workspace_manager\n        .get_or_create_workspace(workspace_id, uid, access_token.clone())\n        .await\n      {\n        panic!(\n          \"Failed to create workspace: {}, token: {:?}\",\n          err, access_token\n        );\n      }\n    } else {\n      client\n        .workspace_manager\n        .get_or_create_workspace(workspace_id, uid, None)\n        .await\n        .unwrap();\n    }\n\n    client\n  }\n\n  /// Creates a new user and test client\n  pub async fn new_user() -> Self {\n    setup_log();\n    let registered_user = generate_unique_registered_user().await;\n    let client = Self::new(registered_user, true).await;\n    let uid = client.uid().await;\n    trace!(\"🤖New user created: {}\", uid);\n    client\n  }\n\n  /// Creates a new user without websocket connection\n  pub async fn new_user_without_ws_conn() -> Self {\n    let registered_user = generate_unique_registered_user().await;\n    Self::new(registered_user, false).await\n  }\n\n  /// Creates a test client for an existing user with a new device\n  pub async fn user_with_new_device(registered_user: User) -> Self {\n    Self::new(registered_user, true).await\n  }\n\n  // === Collab Operations ===\n\n  pub async fn insert_into<S: Prelim>(&self, object_id: &Uuid, key: &str, value: S) {\n    let mut lock = self.collabs.get(object_id).unwrap().collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.insert(key, value);\n  }\n\n  pub async fn client_id(&self, workspace_id: &Uuid) -> ClientID {\n    let workspace = self.workspace_controller_for(*workspace_id).await;\n    workspace.client_id()\n  }\n\n  pub fn subscribe_workspace_notification(\n    &self,\n    workspace_id: &Uuid,\n  ) -> tokio::sync::broadcast::Receiver<WorkspaceNotification> {\n    self\n      .workspace_manager\n      .get_workspace(workspace_id)\n      .unwrap()\n      .subscribe_notification()\n  }\n\n  /// Enables/disables message receiving for debugging (debug builds only)\n  #[cfg(debug_assertions)]\n  pub fn disable_receive_message(&mut self) {\n    self.workspace_manager.set_receive_message(false);\n  }\n\n  #[cfg(debug_assertions)]\n  pub fn enable_receive_message(&mut self) {\n    self.workspace_manager.set_receive_message(true);\n  }\n\n  pub async fn insert_view_to_general_space(\n    &self,\n    workspace_id: &Uuid,\n    view_id: &str,\n    view_name: &str,\n    view_layout: ViewLayout,\n    uid: i64,\n  ) {\n    let mut folder = self.get_folder(*workspace_id).await;\n    let general_space_id = folder\n      .get_view(&workspace_id.to_string(), uid)\n      .unwrap()\n      .children\n      .first()\n      .unwrap()\n      .clone();\n    let view = NestedChildViewBuilder::new(self.uid().await, general_space_id.id.clone())\n      .with_view_id(view_id.to_string())\n      .with_name(view_name)\n      .with_layout(view_layout)\n      .build()\n      .view;\n    {\n      let mut txn = folder.collab.transact_mut();\n      folder.body.views.insert(&mut txn, view, None, uid);\n    }\n    let folder_collab_type = CollabType::Folder;\n    self\n      .api_client\n      .update_web_collab(\n        workspace_id,\n        workspace_id,\n        UpdateCollabWebParams {\n          doc_state: folder\n            .encode_collab_v1(|c| folder_collab_type.validate_require_data(c))\n            .unwrap()\n            .doc_state\n            .to_vec(),\n          collab_type: CollabType::Folder,\n        },\n      )\n      .await\n      .unwrap();\n  }\n\n  pub async fn get_folder(&self, workspace_id: Uuid) -> Folder {\n    let uid = self.uid().await;\n    let folder_collab = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_id,\n        CollabType::Folder,\n        workspace_id,\n      ))\n      .await\n      .unwrap()\n      .encode_collab;\n    Folder::from_collab_doc_state(\n      CollabOrigin::Client(CollabClient::new(uid, self.device_id.clone())),\n      folder_collab.into(),\n      &workspace_id.to_string(),\n      self.client_id(&workspace_id).await,\n    )\n    .unwrap()\n  }\n\n  pub async fn get_database(&self, workspace_id: Uuid, database_id: &str) -> Database {\n    let client_id = self.client_id(&workspace_id).await;\n    let service = Arc::new(TestDatabaseCollabService::new(\n      self.api_client.clone(),\n      workspace_id,\n      client_id,\n    ));\n    let context = DatabaseContext::new(service.clone(), service);\n    Database::open(database_id, context).await.unwrap()\n  }\n\n  pub async fn get_document(&self, workspace_id: Uuid, document_id: Uuid) -> Document {\n    let collab = self\n      .get_collab_to_collab(workspace_id, document_id, CollabType::Document)\n      .await\n      .unwrap();\n    Document::open(collab).unwrap()\n  }\n\n  pub async fn get_workspace_database(&self, workspace_id: Uuid) -> WorkspaceDatabase {\n    let workspaces = self.api_client.get_workspaces().await.unwrap();\n    let workspace_database_id = workspaces\n      .iter()\n      .find(|w| w.workspace_id == workspace_id)\n      .unwrap()\n      .database_storage_id;\n\n    let collab = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_database_id,\n        CollabType::WorkspaceDatabase,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n\n    WorkspaceDatabase::from_collab_doc_state(\n      &workspace_database_id.to_string(),\n      CollabOrigin::Empty,\n      collab.encode_collab.into(),\n      self.client_id(&workspace_id).await,\n    )\n    .unwrap()\n  }\n\n  pub async fn get_connect_users(&self, object_id: &Uuid) -> Vec<i64> {\n    #[derive(Deserialize)]\n    struct UserId {\n      pub uid: i64,\n    }\n\n    let lock = self.collabs.get(object_id).unwrap().collab.read().await;\n    lock\n      .get_awareness()\n      .iter()\n      .flat_map(|(_a, client)| match &client.data {\n        None => None,\n        Some(json) => {\n          let user: UserId = serde_json::from_str(json).unwrap();\n          Some(user.uid)\n        },\n      })\n      .collect()\n  }\n\n  pub async fn clean_awareness_state(&self, object_id: &Uuid) {\n    let test_collab = self.collabs.get(object_id).unwrap();\n    let mut lock = test_collab.collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.clean_awareness_state();\n  }\n\n  pub async fn emit_awareness_state(&self, object_id: &Uuid) {\n    let test_collab = self.collabs.get(object_id).unwrap();\n    let mut lock = test_collab.collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.emit_awareness_state();\n  }\n\n  pub async fn get_user_workspace_info(&self) -> AFUserWorkspaceInfo {\n    self.api_client.get_user_workspace_info().await.unwrap()\n  }\n\n  pub async fn open_workspace(&self, workspace_id: &Uuid) -> AFWorkspace {\n    self.workspace_controller_for(*workspace_id).await;\n    self.api_client.open_workspace(workspace_id).await.unwrap()\n  }\n\n  pub async fn get_user_folder(&self) -> Folder {\n    let workspace_id = self.workspace_id().await;\n    let data = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        workspace_id,\n        CollabType::Folder,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n\n    Folder::from_collab_doc_state(\n      CollabOrigin::Empty,\n      data.encode_collab.into(),\n      &workspace_id.to_string(),\n      self.client_id(&workspace_id).await,\n    )\n    .unwrap()\n  }\n\n  pub async fn get_workspace_database_collab(&self, workspace_id: Uuid) -> Collab {\n    let db_storage_id = self.open_workspace(&workspace_id).await.database_storage_id;\n    let collab_resp = self\n      .get_collab(workspace_id, db_storage_id, CollabType::WorkspaceDatabase)\n      .await\n      .unwrap();\n    let client_id = self.client_id(&workspace_id).await;\n    let options = CollabOptions::new(db_storage_id.to_string(), client_id)\n      .with_data_source(collab_resp.encode_collab.into());\n    Collab::new_with_options(CollabOrigin::Server, options).unwrap()\n  }\n\n  pub async fn create_document_collab(&self, workspace_id: Uuid, object_id: Uuid) -> Document {\n    let collab_resp = self\n      .get_collab(workspace_id, object_id, CollabType::Document)\n      .await\n      .unwrap();\n    let options = CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await)\n      .with_data_source(collab_resp.encode_collab.into());\n    let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n    Document::open(collab).unwrap()\n  }\n\n  pub async fn get_db_collab_from_view(&mut self, workspace_id: Uuid, view_id: &Uuid) -> Collab {\n    let ws_db_collab = self.get_workspace_database_collab(workspace_id).await;\n    let ws_db_body = WorkspaceDatabase::open(ws_db_collab).unwrap();\n    let db_id = ws_db_body\n      .get_all_database_meta()\n      .into_iter()\n      .find(|db_meta| db_meta.linked_views.contains(&view_id.to_string()))\n      .unwrap()\n      .database_id\n      .parse::<Uuid>()\n      .unwrap();\n    let db_collab_collab_resp = self\n      .get_collab(workspace_id, db_id, CollabType::Database)\n      .await\n      .unwrap();\n    let options = CollabOptions::new(db_id.to_string(), self.client_id(&workspace_id).await)\n      .with_data_source(db_collab_collab_resp.encode_collab.into());\n    Collab::new_with_options(CollabOrigin::Server, options).unwrap()\n  }\n\n  pub async fn get_user_awareness(&self) -> UserAwareness {\n    let workspace_id = self.workspace_id().await;\n    let profile = self.get_user_profile().await;\n    let awareness_object_id = user_awareness_object_id(&profile.uuid, &workspace_id);\n    let data = self\n      .api_client\n      .get_collab(QueryCollabParams::new(\n        awareness_object_id,\n        CollabType::UserAwareness,\n        workspace_id,\n      ))\n      .await\n      .unwrap();\n    let options = CollabOptions::new(\n      awareness_object_id.to_string(),\n      self.client_id(&workspace_id).await,\n    )\n    .with_data_source(DataSource::DocStateV1(\n      data.encode_collab.doc_state.to_vec(),\n    ));\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options).unwrap();\n\n    UserAwareness::open(collab, None).unwrap()\n  }\n\n  pub async fn try_update_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n    role: AFRole,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n    self\n      .api_client\n      .update_workspace_member(\n        workspace_id,\n        WorkspaceMemberChangeset::new(email).with_role(role),\n      )\n      .await\n  }\n\n  pub async fn invite_and_accepted_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n    role: AFRole,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n\n    self\n      .api_client\n      .invite_workspace_members(\n        workspace_id,\n        vec![WorkspaceMemberInvitation {\n          email,\n          role,\n          skip_email_send: true,\n          ..Default::default()\n        }],\n      )\n      .await?;\n\n    let invitations = other_client\n      .api_client\n      .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Pending))\n      .await\n      .unwrap();\n\n    let target_invitation = invitations\n      .iter()\n      .find(|inv| &inv.workspace_id == workspace_id)\n      .unwrap();\n\n    other_client\n      .api_client\n      .accept_workspace_invitation(target_invitation.invite_id.to_string().as_str())\n      .await\n      .unwrap();\n\n    Ok(())\n  }\n\n  pub async fn try_remove_workspace_member(\n    &self,\n    workspace_id: &Uuid,\n    other_client: &TestClient,\n  ) -> Result<(), AppResponseError> {\n    let email = other_client.email().await;\n    self\n      .api_client\n      .remove_workspace_members(workspace_id, vec![email])\n      .await\n  }\n\n  pub async fn get_workspace_members(&self, workspace_id: &Uuid) -> Vec<AFWorkspaceMember> {\n    self\n      .api_client\n      .get_workspace_members(workspace_id)\n      .await\n      .unwrap()\n  }\n\n  pub async fn try_get_workspace_members(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<AFWorkspaceMember>, AppResponseError> {\n    self.api_client.get_workspace_members(workspace_id).await\n  }\n\n  pub async fn get_workspace_member(&self, workspace_id: Uuid, user_id: i64) -> AFWorkspaceMember {\n    let params = QueryWorkspaceMember {\n      workspace_id,\n      uid: user_id,\n    };\n    self.api_client.get_workspace_member(params).await.unwrap()\n  }\n\n  pub async fn try_get_workspace_member(\n    &self,\n    workspace_id: Uuid,\n    user_id: i64,\n  ) -> Result<AFWorkspaceMember, AppResponseError> {\n    let params = QueryWorkspaceMember {\n      workspace_id,\n      uid: user_id,\n    };\n\n    self.api_client.get_workspace_member(params).await\n  }\n\n  /// Waits for an object to complete synchronization with default timeout\n  pub async fn wait_object_sync_complete(&self, object_id: &Uuid) -> Result<(), Error> {\n    self\n      .wait_object_sync_complete_with_secs(object_id, TestClientConstants::SYNC_TIMEOUT_SECS)\n      .await\n  }\n\n  /// Waits for an object to complete synchronization with custom timeout\n  pub async fn wait_object_sync_complete_with_secs(\n    &self,\n    object_id: &Uuid,\n    timeout_secs: u64,\n  ) -> Result<(), Error> {\n    let test_collab = self\n      .collabs\n      .get(object_id)\n      .ok_or_else(|| anyhow!(\"Collab not found for object_id: {}\", object_id))?;\n\n    let (current_sync_state, mut sync_state_stream) = {\n      let lock = test_collab.collab.read().await;\n      let changes = lock.subscribe_sync_state();\n      let current_state = lock.get_state().sync_state();\n      (current_state, changes)\n    };\n\n    // If already synced, return immediately\n    if current_sync_state == SyncState::SyncFinished {\n      return Ok(());\n    }\n\n    // Use our async utility for waiting\n    assertion_utils::wait_for_sync_complete(\n      &mut sync_state_stream,\n      current_sync_state,\n      Duration::from_secs(timeout_secs),\n      &test_collab.collab,\n    )\n    .await\n  }\n\n  #[allow(dead_code)]\n  pub async fn get_blob_metadata(&self, workspace_id: &Uuid, file_id: &str) -> BlobMetadata {\n    let url = self.api_client.get_blob_url(workspace_id, file_id);\n    self.api_client.get_blob_metadata(&url).await.unwrap()\n  }\n\n  pub async fn upload_blob<T: Into<Bytes>>(&self, file_id: &str, data: T, mime: &Mime) {\n    let workspace_id = self.workspace_id().await;\n    let url = self.api_client.get_blob_url(&workspace_id, file_id);\n    self.api_client.put_blob(&url, data, mime).await.unwrap()\n  }\n\n  pub async fn delete_file(&self, file_id: &str) {\n    let workspace_id = self.workspace_id().await;\n    let url = self.api_client.get_blob_url(&workspace_id, file_id);\n    self.api_client.delete_blob(&url).await.unwrap();\n  }\n\n  pub async fn get_workspace_usage(&self) -> WorkspaceSpaceUsage {\n    let workspace_id = self.workspace_id().await;\n    self\n      .api_client\n      .get_workspace_usage(&workspace_id)\n      .await\n      .unwrap()\n  }\n\n  /// Gets the workspace ID for the current user\n  pub async fn workspace_id(&self) -> Uuid {\n    self\n      .api_client\n      .get_workspaces()\n      .await\n      .unwrap()\n      .first()\n      .unwrap()\n      .workspace_id\n  }\n\n  /// Gets the email of the current user\n  pub async fn email(&self) -> String {\n    self.api_client.get_profile().await.unwrap().email.unwrap()\n  }\n\n  /// Gets the UID of the current user\n  pub async fn uid(&self) -> i64 {\n    self.api_client.get_profile().await.unwrap().uid\n  }\n\n  /// Gets the full user profile\n  pub async fn get_user_profile(&self) -> AFUserProfile {\n    self.api_client.get_profile().await.unwrap()\n  }\n\n  /// Waits until all embeddings are ready for the given queries\n  pub async fn wait_until_all_embedding(\n    &self,\n    workspace_id: &Uuid,\n    query: Vec<EmbeddedCollabQuery>,\n  ) -> Result<Vec<AFCollabEmbedInfo>, AppResponseError> {\n    let expected_count = query.len();\n\n    retry_api_with_constant_interval(\n      || async {\n        match self\n          .api_client\n          .batch_get_collab_embed_info(workspace_id, query.clone())\n          .await\n        {\n          Ok(items) if items.len() == expected_count => Ok(items),\n          Ok(items) => Err(AppResponseError {\n            code: shared_entity::response::ErrorCode::RecordNotFound,\n            message: format!(\n              \"Expected {} embeddings, got {}\",\n              expected_count,\n              items.len()\n            )\n            .into(),\n          }),\n          Err(e) => Err(e),\n        }\n      },\n      RetryConfig::for_embedding(),\n    )\n    .await\n  }\n\n  /// Waits until embedding is available for a specific object\n  pub async fn wait_until_get_embedding(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<(), AppResponseError> {\n    retry_api_with_constant_interval(\n      || async {\n        self\n          .api_client\n          .get_collab_embed_info(workspace_id, object_id)\n          .await\n          .map(|_| ())\n      },\n      RetryConfig::for_embedding(),\n    )\n    .await\n  }\n\n  /// Waits until search results are available\n  pub async fn wait_unit_get_search_result(\n    &self,\n    workspace_id: &Uuid,\n    query: &str,\n    limit: u32,\n    preview: u32,\n    score_limit: Option<f32>,\n  ) -> Result<Vec<SearchDocumentResponseItem>, AppResponseError> {\n    retry_api_with_constant_interval(\n      || async {\n        let response = self\n          .api_client\n          .search_documents(workspace_id, query, limit, preview, score_limit)\n          .await?;\n\n        if response.is_empty() {\n          Err(AppResponseError {\n            code: shared_entity::response::ErrorCode::RecordNotFound,\n            message: \"No search results found\".into(),\n          })\n        } else {\n          Ok(response)\n        }\n      },\n      RetryConfig::for_search(),\n    )\n    .await\n  }\n\n  pub async fn assert_similarity(\n    &self,\n    workspace_id: &Uuid,\n    input: &str,\n    expected: &str,\n    score: f64,\n    use_embedding: bool,\n  ) {\n    let params = CalculateSimilarityParams {\n      workspace_id: *workspace_id,\n      input: input.to_string(),\n      expected: expected.to_string(),\n      use_embedding,\n    };\n    let resp = self.api_client.calculate_similarity(params).await.unwrap();\n    assert!(\n      resp.score > score,\n      \"Similarity score is too low: {}.\\nexpected: {},\\ninput: {},\\nexpected:{}\",\n      resp.score,\n      score,\n      input,\n      expected\n    );\n  }\n\n  pub async fn create_collab_list(\n    &mut self,\n    workspace_id: &Uuid,\n    params: Vec<CollabParams>,\n  ) -> Result<(), AppResponseError> {\n    self\n      .api_client\n      .create_collab_list(workspace_id, params)\n      .await\n  }\n\n  pub async fn get_collab(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) -> Result<CollabResponse, AppResponseError> {\n    self\n      .api_client\n      .get_collab(QueryCollabParams {\n        workspace_id,\n        inner: QueryCollab {\n          object_id,\n          collab_type,\n        },\n      })\n      .await\n  }\n\n  pub async fn get_collab_to_collab(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) -> Result<Collab, AppResponseError> {\n    let resp = self\n      .get_collab(workspace_id, object_id, collab_type)\n      .await?;\n    let options = CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await)\n      .with_data_source(resp.encode_collab.into());\n    let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n    Ok(collab)\n  }\n\n  pub async fn batch_get_collab(\n    &mut self,\n    workspace_id: &Uuid,\n    params: Vec<QueryCollab>,\n  ) -> Result<BatchQueryCollabResult, AppResponseError> {\n    self.api_client.batch_get_collab(workspace_id, params).await\n  }\n\n  async fn workspace_controller_for(&self, workspace_id: Uuid) -> Arc<WorkspaceController> {\n    let uid = self.api_client.get_profile().await.unwrap().uid;\n    let access_token = self.api_client.access_token().ok();\n    self\n      .workspace_manager\n      .get_or_create_workspace(workspace_id, uid, access_token)\n      .await\n      .unwrap()\n  }\n\n  pub async fn create_and_edit_collab(\n    &mut self,\n    workspace_id: Uuid,\n    collab_type: CollabType,\n  ) -> Uuid {\n    let object_id = Uuid::new_v4();\n    self\n      .create_and_edit_collab_with_data(object_id, workspace_id, collab_type, None, true)\n      .await;\n    object_id\n  }\n\n  #[allow(unused_variables)]\n  pub async fn create_and_edit_collab_with_data(\n    &mut self,\n    object_id: Uuid,\n    workspace_id: Uuid,\n    collab_type: CollabType,\n    encoded_collab_v1: Option<EncodedCollab>,\n    wait_until_doc_synced: bool,\n  ) {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let mut collab = match encoded_collab_v1 {\n      None => {\n        let options =\n          CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await);\n        Collab::new_with_options(origin.clone(), options).unwrap()\n      },\n      Some(data) => {\n        let options =\n          CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await)\n            .with_data_source(data.into());\n        Collab::new_with_options(origin.clone(), options).unwrap()\n      },\n    };\n\n    collab.emit_awareness_state();\n    let encoded_collab_v1 = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .unwrap()\n      .encode_to_bytes()\n      .unwrap();\n\n    self\n      .api_client\n      .create_collab(CreateCollabParams {\n        object_id,\n        encoded_collab_v1,\n        collab_type,\n        workspace_id,\n      })\n      .await\n      .unwrap();\n\n    let collab = Arc::new(RwLock::from(collab));\n    let collab_ref = collab.clone() as CollabRef;\n    {\n      let workspace = self.workspace_controller_for(workspace_id).await;\n      workspace\n        .bind_and_cache_collab_ref(&collab_ref, collab_type)\n        .await\n        .unwrap();\n    }\n    {\n      let mut lock = collab.write().await;\n      let collab = (*lock).borrow_mut();\n      collab.initialize();\n    }\n    let test_collab = TestCollab { origin, collab };\n    self.collabs.insert(object_id, test_collab);\n    if wait_until_doc_synced {\n      self.wait_object_sync_complete(&object_id).await.unwrap();\n    }\n  }\n\n  pub async fn open_workspace_collab(&mut self, workspace_id: Uuid) {\n    self\n      .open_collab(workspace_id, workspace_id, CollabType::Folder)\n      .await;\n  }\n\n  #[allow(clippy::await_holding_lock)]\n  pub async fn open_collab(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) {\n    let params = QueryCollabParams {\n      workspace_id,\n      inner: QueryCollab {\n        object_id,\n        collab_type,\n      },\n    };\n    let doc_state = self\n      .api_client\n      .get_collab(params)\n      .await\n      .map(|c| c.encode_collab.doc_state.to_vec())\n      .unwrap_or_default();\n    self\n      .open_collab_with_doc_state(workspace_id, object_id, collab_type, doc_state)\n      .await\n  }\n\n  #[allow(unused_variables)]\n  pub async fn open_collab_with_doc_state(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    doc_state: Vec<u8>,\n  ) {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let options = CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await)\n      .with_data_source(DataSource::DocStateV1(doc_state));\n    let mut collab = Collab::new_with_options(origin.clone(), options).unwrap();\n    collab_type.validate_require_data(&collab).unwrap();\n    collab.emit_awareness_state();\n\n    let collab = Arc::new(RwLock::from(collab));\n    let collab_ref = collab.clone() as CollabRef;\n\n    {\n      let workspace = self.workspace_controller_for(workspace_id).await;\n      workspace\n        .bind_and_cache_collab_ref(&collab_ref, collab_type)\n        .await\n        .unwrap();\n    }\n    {\n      let mut lock = collab.write().await;\n      let collab = (*lock).borrow_mut();\n      collab.initialize();\n    }\n    let test_collab = TestCollab { origin, collab };\n    self.collabs.insert(object_id, test_collab);\n  }\n\n  #[allow(unused_variables)]\n  pub async fn create_collab_with_data(\n    &mut self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    encoded_collab_v1: EncodedCollab,\n  ) -> Result<(), AppResponseError> {\n    // Subscribe to object\n    let origin = CollabOrigin::Client(CollabClient::new(self.uid().await, self.device_id.clone()));\n    let options = CollabOptions::new(object_id.to_string(), self.client_id(&workspace_id).await)\n      .with_data_source(encoded_collab_v1.into());\n    let collab = Collab::new_with_options(origin.clone(), options).unwrap();\n\n    let encoded_collab_v1 = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .unwrap()\n      .encode_to_bytes()\n      .unwrap();\n\n    self\n      .api_client\n      .create_collab(CreateCollabParams {\n        object_id,\n        encoded_collab_v1,\n        collab_type,\n        workspace_id,\n      })\n      .await\n  }\n\n  #[cfg(not(target_arch = \"wasm32\"))]\n  pub async fn post_realtime_binary(&self, message: Vec<u8>) -> Result<(), AppResponseError> {\n    let message = client_websocket::Message::binary(message);\n    self\n      .api_client\n      .post_realtime_msg(&self.device_id, message)\n      .await\n  }\n\n  pub async fn disconnect(&self) {\n    self.workspace_manager.disconnect_all().await.unwrap();\n  }\n\n  pub async fn reconnect(&self) {\n    self\n      .workspace_manager\n      .connect_all(self.api_client.access_token().unwrap())\n      .await\n      .unwrap();\n    tokio::time::sleep(Duration::from_secs(2)).await;\n  }\n\n  pub async fn get_edit_collab_json(&self, object_id: &Uuid) -> Value {\n    let lock = self.collabs.get(object_id).unwrap().collab.read().await;\n    lock.to_json_value()\n  }\n\n  /// data: [(view_id, meta_json, blob_hex)]\n  pub async fn publish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    data: Vec<(Uuid, &str, &str)>,\n    comments_enabled: bool,\n    duplicate_enabled: bool,\n  ) {\n    let pub_items = data\n      .into_iter()\n      .map(|(view_id, meta_json, blob_hex)| {\n        let meta: PublishViewMetaData = serde_json::from_str(meta_json).unwrap();\n        let blob = hex::decode(blob_hex).unwrap();\n        PublishCollabItem {\n          meta: PublishCollabMetadata {\n            view_id,\n            publish_name: uuid::Uuid::new_v4().to_string(),\n            metadata: meta,\n          },\n          data: blob,\n          comments_enabled,\n          duplicate_enabled,\n        }\n      })\n      .collect();\n\n    self\n      .api_client\n      .publish_collabs(workspace_id, pub_items)\n      .await\n      .unwrap();\n  }\n\n  pub async fn duplicate_published_to_workspace(\n    &self,\n    dest_workspace_id: Uuid,\n    src_view_id: Uuid,\n    dest_view_id: Uuid,\n  ) {\n    self\n      .api_client\n      .duplicate_published_to_workspace(\n        dest_workspace_id,\n        &PublishedDuplicate {\n          published_view_id: src_view_id,\n          dest_view_id,\n        },\n      )\n      .await\n      .unwrap();\n\n    // wait a while for folder collab to be synced\n    tokio::time::sleep(Duration::from_secs(1)).await;\n  }\n}\n\npub struct TestCollab {\n  #[allow(dead_code)]\n  pub origin: CollabOrigin,\n  pub collab: Arc<RwLock<Collab>>,\n}\n\nimpl TestCollab {\n  pub async fn encode_collab(&self) -> EncodedCollab {\n    let lock = self.collab.read().await;\n    lock\n      .encode_collab_v1(|_| Ok::<(), anyhow::Error>(()))\n      .unwrap()\n  }\n}\n\n#[async_trait]\nimpl JsonAssertable for TestCollab {\n  async fn get_json(&self) -> Result<Value, Error> {\n    let lock = self.collab.read().await;\n    Ok(lock.to_json_value())\n  }\n}\n\n/// Wrapper to make TestClient's collabs JSON assertable\nimpl TestClient {\n  /// Gets a collab by ID and provides JSON assertion capabilities\n  pub fn get_collab_assertable(&self, object_id: &Uuid) -> Option<&TestCollab> {\n    self.collabs.get(object_id)\n  }\n}\n\n/// This is a convenience function that uses the new assertion utilities.\n/// Consider using `assert_server_collab_eventually` directly for more control.\npub async fn assert_server_collab(\n  workspace_id: Uuid,\n  client: &mut client_api::Client,\n  object_id: Uuid,\n  collab_type: &CollabType,\n  timeout_secs: u64,\n  expected: Value,\n) -> Result<(), Error> {\n  let config = crate::test_client_config::AssertionConfig {\n    timeout: Duration::from_secs(timeout_secs),\n    ..Default::default()\n  };\n\n  assert_server_collab_eventually(\n    client,\n    workspace_id,\n    object_id,\n    *collab_type,\n    expected,\n    config,\n  )\n  .await\n}\n\npub async fn assert_client_collab_value(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  expected: Value,\n) -> Result<(), Error> {\n  let test_collab = client\n    .collabs\n    .get(object_id)\n    .ok_or_else(|| anyhow!(\"Collab not found for object_id: {}\", object_id))?;\n\n  let config = crate::test_client_config::AssertionConfig {\n    timeout: Duration::from_secs(TestClientConstants::SYNC_TIMEOUT_SECS),\n    ..Default::default()\n  };\n\n  test_collab.assert_json_eventually(expected, config).await\n}\n\n/// Asserts that a client collab's specific key eventually matches the expected value\npub async fn assert_client_collab_within_secs(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  key: &str,\n  expected: Value,\n  secs: u64,\n) {\n  let test_collab = client\n    .collabs\n    .get(object_id)\n    .unwrap_or_else(|| panic!(\"Collab not found for object_id: {}\", object_id));\n\n  let config = crate::test_client_config::AssertionConfig {\n    timeout: Duration::from_secs(secs),\n    ..Default::default()\n  };\n\n  test_collab\n    .assert_json_key_eventually(key, expected, config)\n    .await\n    .unwrap_or_else(|e| panic!(\"Client collab assertion failed: {}\", e));\n}\n\n/// Asserts that a client collab eventually includes the expected values\npub async fn assert_client_collab_include_value(\n  client: &mut TestClient,\n  object_id: &Uuid,\n  expected: Value,\n) -> Result<(), Error> {\n  let test_collab = client\n    .collabs\n    .get(object_id)\n    .ok_or_else(|| anyhow!(\"Collab not found for object_id: {}\", object_id))?;\n\n  let config = crate::test_client_config::AssertionConfig {\n    timeout: Duration::from_secs(TestClientConstants::SYNC_TIMEOUT_SECS),\n    ..Default::default()\n  };\n\n  test_collab.assert_json_eventually(expected, config).await\n}\n\n/// Collects all answer values from a question stream\npub async fn collect_answer(mut stream: QuestionStream) -> String {\n  let mut answer = String::new();\n  while let Some(value) = stream.next().await {\n    match value.unwrap() {\n      QuestionStreamValue::Answer { value } => {\n        answer.push_str(&value);\n      },\n      QuestionStreamValue::Metadata { .. } => {},\n      QuestionStreamValue::SuggestedQuestion { .. } => {},\n      QuestionStreamValue::FollowUp { .. } => {},\n    }\n  }\n  answer\n}\n\n/// Collects answer and comment values from a completion stream\npub async fn collect_completion_v2(mut stream: CompletionStream) -> (String, String) {\n  let mut answer = String::new();\n  let mut comment = String::new();\n  while let Some(value) = stream.next().await {\n    match value.unwrap() {\n      CompletionStreamValue::Answer { value } => {\n        answer.push_str(&value);\n      },\n      CompletionStreamValue::Comment { value } => {\n        comment.push_str(&value);\n      },\n    }\n  }\n  (answer, comment)\n}\n"
  },
  {
    "path": "libs/client-api-test/src/user.rs",
    "content": "use crate::client::{localhost_client, LOCALHOST_GOTRUE};\nuse crate::log::setup_log;\nuse client_api::Client;\nuse lazy_static::lazy_static;\nuse uuid::Uuid;\n\nlazy_static! {\n  pub static ref ADMIN_USER: User = {\n    dotenvy::dotenv().ok();\n    User {\n      email: std::env::var(\"GOTRUE_ADMIN_EMAIL\").unwrap_or(\"admin@example.com\".to_string()),\n      password: std::env::var(\"GOTRUE_ADMIN_PASSWORD\").unwrap_or(\"password\".to_string()),\n    }\n  };\n  static ref ADMIN_SIGN_IN_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::new(());\n}\n\n#[derive(Clone, Debug)]\npub struct User {\n  pub email: String,\n  pub password: String,\n}\n\npub fn generate_unique_email() -> String {\n  format!(\"user_{}@appflowy.io\", Uuid::new_v4())\n}\n\npub async fn admin_user_client() -> Client {\n  let admin_client = localhost_client();\n  #[cfg(target_arch = \"wasm32\")]\n  {\n    let msg = format!(\"{}\", admin_client);\n    web_sys::console::log_1(&msg.into());\n  }\n\n  let _guard = ADMIN_SIGN_IN_MUTEX.lock().await;\n  let _is_new = admin_client\n    .sign_in_password(&ADMIN_USER.email, &ADMIN_USER.password)\n    .await\n    .unwrap();\n  admin_client\n}\n\npub async fn generate_unique_registered_user_with_email(email: &str) -> User {\n  let admin_client = admin_user_client().await;\n  // create new user\n  let password = \"Hello123!\";\n  admin_client\n    .create_email_verified_user(email, password)\n    .await\n    .unwrap();\n\n  User {\n    email: email.to_string(),\n    password: password.to_string(),\n  }\n}\n\npub async fn generate_unique_registered_user_client_with_email(email: &str) -> (Client, User) {\n  setup_log();\n  let registered_user = generate_unique_registered_user_with_email(email).await;\n  let registered_user_client = localhost_client();\n  registered_user_client\n    .sign_in_password(&registered_user.email, &registered_user.password)\n    .await\n    .unwrap();\n  (registered_user_client, registered_user)\n}\n\npub async fn generate_unique_registered_user() -> User {\n  let email = generate_unique_email();\n  generate_unique_registered_user_with_email(&email).await\n}\n\npub async fn generate_unique_registered_user_client() -> (Client, User) {\n  setup_log();\n  let email = generate_unique_email();\n  generate_unique_registered_user_client_with_email(&email).await\n}\n\npub async fn generate_sign_in_action_link(email: &str) -> String {\n  setup_log();\n  let admin_client = admin_user_client().await;\n  admin_client\n    .generate_sign_in_action_link(email)\n    .await\n    .unwrap()\n}\n\n// same as generate_unique_registered_user_client\n// but with specific email\npub async fn api_client_with_email(user_email: &str) -> client_api::Client {\n  let new_user_sign_in_link = {\n    let admin_client = admin_user_client().await;\n    admin_client\n      .generate_sign_in_action_link(user_email)\n      .await\n      .unwrap()\n  };\n\n  let client = localhost_client();\n  let appflowy_sign_in_url = client\n    .extract_sign_in_url(&new_user_sign_in_link)\n    .await\n    .unwrap();\n  client\n    .sign_in_with_url(&appflowy_sign_in_url)\n    .await\n    .unwrap();\n\n  client\n}\n\npub fn localhost_gotrue_client() -> gotrue::api::Client {\n  let reqwest_client = reqwest::Client::new();\n  gotrue::api::Client::new(reqwest_client, &LOCALHOST_GOTRUE)\n}\n"
  },
  {
    "path": "libs/client-api-test/src/workspace_ops.rs",
    "content": "use anyhow::{anyhow, Error};\nuse dashmap::DashMap;\nuse std::sync::Arc;\nuse uuid::Uuid;\n\nuse client_api::v2::{WorkspaceController, WorkspaceControllerOptions, WorkspaceId};\nuse tempfile::TempDir;\n\nuse crate::LOCALHOST_WS_V2;\n\n/// Handles workspace-related operations for the test client\npub struct WorkspaceManager {\n  pub workspaces: DashMap<WorkspaceId, Arc<WorkspaceController>>,\n  device_id: String,\n  temp_dir: Arc<TempDir>,\n}\n\nimpl WorkspaceManager {\n  pub fn new(device_id: String, temp_dir: Arc<TempDir>) -> Self {\n    Self {\n      workspaces: DashMap::new(),\n      device_id,\n      temp_dir,\n    }\n  }\n\n  /// Gets or creates a workspace controller for the given workspace ID\n  pub async fn get_or_create_workspace(\n    &self,\n    workspace_id: Uuid,\n    uid: i64,\n    access_token: Option<String>,\n  ) -> Result<Arc<WorkspaceController>, Error> {\n    match self.workspaces.entry(workspace_id) {\n      dashmap::mapref::entry::Entry::Occupied(e) => Ok(e.get().clone()),\n      dashmap::mapref::entry::Entry::Vacant(e) => {\n        let workspace = self.create_workspace_controller(workspace_id, uid).await?;\n\n        if let Some(token) = access_token {\n          workspace.connect(token).await?;\n        }\n\n        Ok(e.insert(Arc::new(workspace)).value().clone())\n      },\n    }\n  }\n\n  /// Creates a new workspace controller without connecting\n  async fn create_workspace_controller(\n    &self,\n    workspace_id: Uuid,\n    uid: i64,\n  ) -> Result<WorkspaceController, Error> {\n    let db_path = self.get_workspace_db_path(workspace_id).await?;\n\n    let workspace = WorkspaceController::new(\n      WorkspaceControllerOptions {\n        url: LOCALHOST_WS_V2.to_string(),\n        workspace_id,\n        uid,\n        device_id: self.device_id.clone(),\n        sync_eagerly: true,\n      },\n      &db_path,\n    )?;\n\n    Ok(workspace)\n  }\n\n  /// Gets the database path for a workspace\n  async fn get_workspace_db_path(&self, workspace_id: Uuid) -> Result<String, Error> {\n    let db_path = self\n      .temp_dir\n      .path()\n      .to_str()\n      .ok_or_else(|| anyhow!(\"Invalid temp directory path\"))?;\n    let db_path = format!(\"{}/{}/{}\", db_path, self.device_id, workspace_id);\n    tokio::fs::create_dir_all(&db_path).await?;\n    Ok(db_path)\n  }\n\n  /// Connects all workspaces\n  pub async fn connect_all(&self, access_token: String) -> Result<(), Error> {\n    let mut errors = Vec::new();\n\n    for entry in self.workspaces.iter() {\n      if let Err(e) = entry.value().connect(access_token.clone()).await {\n        errors.push(e);\n      }\n    }\n\n    if !errors.is_empty() {\n      panic!(\"Failed to connect some workspaces: {:?}\", errors);\n    }\n\n    Ok(())\n  }\n\n  /// Disconnects all workspaces\n  pub async fn disconnect_all(&self) -> Result<(), Error> {\n    let mut errors = Vec::new();\n\n    for entry in self.workspaces.iter() {\n      if let Err(e) = entry.value().disconnect().await {\n        errors.push(e);\n      }\n    }\n\n    if !errors.is_empty() {\n      return Err(anyhow!(\n        \"Failed to disconnect some workspaces: {:?}\",\n        errors\n      ));\n    }\n\n    Ok(())\n  }\n\n  /// Gets a workspace by ID\n  pub fn get_workspace(&self, workspace_id: &Uuid) -> Option<Arc<WorkspaceController>> {\n    self\n      .workspaces\n      .get(workspace_id)\n      .map(|entry| entry.value().clone())\n  }\n\n  /// Enables/disables message receiving for all workspaces (debug builds only)\n  #[cfg(debug_assertions)]\n  pub fn set_receive_message(&self, enabled: bool) {\n    for mut entry in self.workspaces.iter_mut() {\n      let workspace = entry.value_mut();\n      if enabled {\n        workspace.enable_receive_message();\n      } else {\n        workspace.disable_receive_message();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-websocket/Cargo.toml",
    "content": "[package]\nname = \"client-websocket\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[features]\nnative-tls = [\"tokio-tungstenite/native-tls\"]\nnative-tls-vendored = [\"native-tls\", \"tokio-tungstenite/native-tls-vendored\"]\nrustls-tls-native-roots = [\n  \"__rustls-tls\",\n  \"tokio-tungstenite/rustls-tls-native-roots\",\n]\nrustls-tls-webpki-roots = [\n  \"__rustls-tls\",\n  \"tokio-tungstenite/rustls-tls-webpki-roots\",\n]\n__rustls-tls = []\n\n[dependencies]\nthiserror = \"1\"\nhttparse = \"1.8\"\nfutures-util = { version = \"0.3\", default-features = false, features = [\n  \"sink\",\n  \"std\",\n] }\n\n[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies]\ntokio-tungstenite.workspace = true\ntokio = { workspace = true, features = [\"net\"] }\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nwasm-bindgen = \"0.2\"\njs-sys = \"0.3\"\nfutures-channel = { version = \"0.3\" }\npercent-encoding = \"2.3.1\"\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies.web-sys]\nversion = \"0.3\"\nfeatures = [\n  \"WebSocket\",\n  \"MessageEvent\",\n  \"CloseEvent\",\n  \"Event\",\n  \"ErrorEvent\",\n  \"BinaryType\",\n  \"Blob\",\n]\n"
  },
  {
    "path": "libs/client-websocket/src/error.rs",
    "content": "use http::{header::HeaderName, Response};\nuse std::{io, result, str, string};\nuse thiserror::Error;\nuse tokio_tungstenite::tungstenite::http;\n\n/// These error types are copy-pasted from the tokio_tungstenite crate.\npub type Result<T, E = Error> = result::Result<T, E>;\n\n/// Possible WebSocket errors.\n#[derive(Error, Debug)]\npub enum Error {\n  /// WebSocket connection closed normally. This informs you of the close.\n  /// It's not an error as such and nothing wrong happened.\n  ///\n  /// This is returned as soon as the close handshake is finished (we have both sent and\n  /// received a close frame) on the server end and as soon as the server has closed the\n  /// underlying connection if this endpoint is a client.\n  ///\n  /// Thus when you receive this, it is safe to drop the underlying connection.\n  ///\n  /// Receiving this error means that the WebSocket object is not usable anymore and the\n  /// only meaningful action with it is dropping it.\n  #[error(\"Connection closed normally\")]\n  ConnectionClosed,\n  /// Trying to work with already closed connection.\n  ///\n  /// Trying to read or write after receiving `ConnectionClosed` causes this.\n  ///\n  /// As opposed to `ConnectionClosed`, this indicates your code tries to operate on the\n  /// connection when it really shouldn't anymore, so this really indicates a programmer\n  /// error on your part.\n  #[error(\"Trying to work with closed connection\")]\n  AlreadyClosed,\n  /// Input-output error. Apart from WouldBlock, these are generally errors with the\n  /// underlying connection and you should probably consider them fatal.\n  #[error(\"IO error: {0}\")]\n  Io(#[from] io::Error),\n  /// TLS error.\n  ///\n  /// Note that this error variant is enabled unconditionally even if no TLS feature is enabled,\n  /// to provide a feature-agnostic API surface.\n  #[cfg(not(target_arch = \"wasm32\"))]\n  #[error(\"TLS error: {0}\")]\n  Tls(#[from] tokio_tungstenite::tungstenite::error::TlsError),\n  /// - When reading: buffer capacity exhausted.\n  /// - When writing: your message is bigger than the configured max message size\n  ///   (64MB by default).\n  #[error(\"Space limit exceeded: {0}\")]\n  Capacity(#[from] CapacityError),\n  /// Protocol violation.\n  #[error(\"WebSocket protocol error: {0}\")]\n  Protocol(#[from] ProtocolError),\n  #[error(\"Write buffer is full\")]\n  WriteBufferFull(crate::Message),\n  /// UTF coding error.\n  #[error(\"UTF-8 encoding error\")]\n  Utf8,\n  #[error(\"Attack attempt detected\")]\n  AttackAttempt,\n  #[error(\"URL error: {0}\")]\n  Url(#[from] UrlError),\n  #[error(\"HTTP error: {}\", .0.status())]\n  Http(Box<Response<Option<Vec<u8>>>>),\n  #[error(\"HTTP format error: {0}\")]\n  HttpFormat(#[from] http::Error),\n  #[error(\"Parsing blobs is unsupported\")]\n  BlobFormatUnsupported,\n  #[error(\"Unknown data format encountered\")]\n  UnknownFormat,\n}\n\nimpl From<str::Utf8Error> for Error {\n  fn from(_: str::Utf8Error) -> Self {\n    Error::Utf8\n  }\n}\n\nimpl From<string::FromUtf8Error> for Error {\n  fn from(_: string::FromUtf8Error) -> Self {\n    Error::Utf8\n  }\n}\n\nimpl From<http::header::InvalidHeaderValue> for Error {\n  fn from(err: http::header::InvalidHeaderValue) -> Self {\n    Error::HttpFormat(err.into())\n  }\n}\n\nimpl From<http::header::InvalidHeaderName> for Error {\n  fn from(err: http::header::InvalidHeaderName) -> Self {\n    Error::HttpFormat(err.into())\n  }\n}\n\nimpl From<http::header::ToStrError> for Error {\n  fn from(_: http::header::ToStrError) -> Self {\n    Error::Utf8\n  }\n}\n\nimpl From<http::uri::InvalidUri> for Error {\n  fn from(err: http::uri::InvalidUri) -> Self {\n    Error::HttpFormat(err.into())\n  }\n}\n\nimpl From<http::status::InvalidStatusCode> for Error {\n  fn from(err: http::status::InvalidStatusCode) -> Self {\n    Error::HttpFormat(err.into())\n  }\n}\n\nimpl From<httparse::Error> for Error {\n  fn from(err: httparse::Error) -> Self {\n    match err {\n      httparse::Error::TooManyHeaders => Error::Capacity(CapacityError::TooManyHeaders),\n      e => Error::Protocol(ProtocolError::HttparseError(e)),\n    }\n  }\n}\n\n/// Indicates the specific type/cause of a capacity error.\n#[derive(Error, Debug, PartialEq, Eq, Clone, Copy)]\npub enum CapacityError {\n  /// Too many headers provided (see [`httparse::Error::TooManyHeaders`]).\n  #[error(\"Too many headers\")]\n  TooManyHeaders,\n  /// Received header is too long.\n  /// Message is bigger than the maximum allowed size.\n  #[error(\"Message too long: {size} > {max_size}\")]\n  MessageTooLong {\n    /// The size of the message.\n    size: usize,\n    /// The maximum allowed message size.\n    max_size: usize,\n  },\n}\n\n/// Indicates the specific type/cause of a protocol error.\n#[derive(Error, Debug, PartialEq, Eq, Clone)]\npub enum ProtocolError {\n  /// Use of the wrong HTTP method (the WebSocket protocol requires the GET method be used).\n  #[error(\"Unsupported HTTP method used - only GET is allowed\")]\n  WrongHttpMethod,\n  /// Wrong HTTP version used (the WebSocket protocol requires version 1.1 or higher).\n  #[error(\"HTTP version must be 1.1 or higher\")]\n  WrongHttpVersion,\n  /// Missing `Connection: upgrade` HTTP header.\n  #[error(\"No \\\"Connection: upgrade\\\" header\")]\n  MissingConnectionUpgradeHeader,\n  /// Missing `Upgrade: websocket` HTTP header.\n  #[error(\"No \\\"Upgrade: websocket\\\" header\")]\n  MissingUpgradeWebSocketHeader,\n  /// Missing `Sec-WebSocket-Version: 13` HTTP header.\n  #[error(\"No \\\"Sec-WebSocket-Version: 13\\\" header\")]\n  MissingSecWebSocketVersionHeader,\n  /// Missing `Sec-WebSocket-Key` HTTP header.\n  #[error(\"No \\\"Sec-WebSocket-Key\\\" header\")]\n  MissingSecWebSocketKey,\n  /// The `Sec-WebSocket-Accept` header is either not present or does not specify the correct key value.\n  #[error(\"Key mismatch in \\\"Sec-WebSocket-Accept\\\" header\")]\n  SecWebSocketAcceptKeyMismatch,\n  /// Garbage data encountered after client request.\n  #[error(\"Junk after client request\")]\n  JunkAfterRequest,\n  /// Custom responses must be unsuccessful.\n  #[error(\"Custom response must not be successful\")]\n  CustomResponseSuccessful,\n  /// Invalid header is passed. This header is formed by the library automatically\n  /// and must not be overwritten by the user.\n  #[error(\"Not allowed to pass overwrite the standard header {0}\")]\n  InvalidHeader(HeaderName),\n  /// No more data while still performing handshake.\n  #[error(\"Handshake not finished\")]\n  HandshakeIncomplete,\n  /// Wrapper around a [`httparse::Error`] value.\n  #[error(\"httparse error: {0}\")]\n  HttparseError(#[from] httparse::Error),\n  /// Not allowed to send after having sent a closing frame.\n  #[error(\"Sending after closing is not allowed\")]\n  SendAfterClosing,\n  /// Remote sent data after sending a closing frame.\n  #[error(\"Remote sent after having closed\")]\n  ReceivedAfterClosing,\n  /// Reserved bits in frame header are non-zero.\n  #[error(\"Reserved bits are non-zero\")]\n  NonZeroReservedBits,\n  /// The server must close the connection when an unmasked frame is received.\n  #[error(\"Received an unmasked frame from client\")]\n  UnmaskedFrameFromClient,\n  /// The client must close the connection when a masked frame is received.\n  #[error(\"Received a masked frame from server\")]\n  MaskedFrameFromServer,\n  /// Control frames must not be fragmented.\n  #[error(\"Fragmented control frame\")]\n  FragmentedControlFrame,\n  /// Control frames must have a payload of 125 bytes or less.\n  #[error(\"Control frame too big (payload must be 125 bytes or less)\")]\n  ControlFrameTooBig,\n  /// Type of control frame not recognised.\n  #[error(\"Unknown control frame type: {0}\")]\n  UnknownControlFrameType(u8),\n  /// Type of data frame not recognised.\n  #[error(\"Unknown data frame type: {0}\")]\n  UnknownDataFrameType(u8),\n  /// Received a continue frame despite there being nothing to continue.\n  #[error(\"Continue frame but nothing to continue\")]\n  UnexpectedContinueFrame,\n  /// Received data while waiting for more fragments.\n  #[error(\"While waiting for more fragments received: {0}\")]\n  ExpectedFragment(Data),\n  /// Connection closed without performing the closing handshake.\n  #[error(\"Connection reset without closing handshake\")]\n  ResetWithoutClosingHandshake,\n  /// Encountered an invalid opcode.\n  #[error(\"Encountered invalid opcode: {0}\")]\n  InvalidOpcode(u8),\n  /// The payload for the closing frame is invalid.\n  #[error(\"Invalid close sequence\")]\n  InvalidCloseSequence,\n}\n\n/// Indicates the specific type/cause of URL error.\n#[derive(Error, Debug, PartialEq, Eq)]\npub enum UrlError {\n  /// TLS is used despite not being compiled with the TLS feature enabled.\n  #[error(\"TLS support not compiled in\")]\n  TlsFeatureNotEnabled,\n  /// The URL does not include a host name.\n  #[error(\"No host name in the URL\")]\n  NoHostName,\n  /// Failed to connect with this URL.\n  #[error(\"Unable to connect to {0}\")]\n  UnableToConnect(String),\n  /// Unsupported URL scheme used (only `ws://` or `wss://` may be used).\n  #[error(\"URL scheme not supported\")]\n  UnsupportedUrlScheme,\n  /// The URL host name, though included, is empty.\n  #[error(\"URL contains empty host name\")]\n  EmptyHostName,\n  /// The URL does not include a path/query.\n  #[error(\"No path/query in URL\")]\n  NoPathOrQuery,\n}\n\n/// Data opcodes as in RFC 6455\n#[derive(Debug, PartialEq, Eq, Clone, Copy)]\npub enum Data {\n  /// 0x0 denotes a continuation frame\n  Continue,\n  /// 0x1 denotes a text frame\n  Text,\n  /// 0x2 denotes a binary frame\n  Binary,\n  /// 0x3-7 are reserved for further non-control frames\n  Reserved(u8),\n}\n\nimpl std::fmt::Display for Data {\n  fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n    match *self {\n      Data::Continue => write!(f, \"CONTINUE\"),\n      Data::Text => write!(f, \"TEXT\"),\n      Data::Binary => write!(f, \"BINARY\"),\n      Data::Reserved(x) => write!(f, \"RESERVED_DATA_{}\", x),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-websocket/src/lib.rs",
    "content": "mod error;\nmod message;\n#[cfg(not(target_arch = \"wasm32\"))]\nmod native;\n#[cfg(target_arch = \"wasm32\")]\nmod web;\n\npub use error::{Error, ProtocolError, Result};\npub use message::coding::*;\npub use message::CloseFrame;\npub use message::Message;\n#[cfg(not(target_arch = \"wasm32\"))]\nuse native as ws;\nuse tokio_tungstenite::tungstenite::http::HeaderMap;\n#[cfg(target_arch = \"wasm32\")]\nuse web as ws;\npub use ws::WebSocketStream;\n\npub async fn connect_async<S: AsRef<str>>(url: S, headers: HeaderMap) -> Result<WebSocketStream> {\n  ws::connect_async(url.as_ref(), headers).await\n}\n"
  },
  {
    "path": "libs/client-websocket/src/message.rs",
    "content": "/// An enum representing the various forms of a WebSocket message.\n#[derive(Debug, Eq, PartialEq, Clone)]\npub enum Message {\n  /// A text WebSocket message\n  Text(String),\n  /// A binary WebSocket message\n  Binary(Vec<u8>),\n  /// A close message with the optional close frame.\n  Close(Option<CloseFrame<'static>>),\n  Ping(Vec<u8>),\n  Pong(Vec<u8>),\n}\n\nimpl Message {\n  /// Create a new text WebSocket message from a stringable.\n  pub fn text<S>(string: S) -> Message\n  where\n    S: Into<String>,\n  {\n    Message::Text(string.into())\n  }\n\n  /// Create a new binary WebSocket message by converting to Vec<u8>.\n  pub fn binary<B>(bin: B) -> Message\n  where\n    B: Into<Vec<u8>>,\n  {\n    Message::Binary(bin.into())\n  }\n\n  /// Indicates whether a message is a text message.\n  pub fn is_text(&self) -> bool {\n    matches!(*self, Message::Text(_))\n  }\n\n  /// Indicates whether a message is a binary message.\n  pub fn is_binary(&self) -> bool {\n    matches!(*self, Message::Binary(_))\n  }\n\n  /// Indicates whether a message is a ping message.\n  pub fn is_ping(&self) -> bool {\n    matches!(self, Message::Ping(_))\n  }\n\n  /// Indicates whether a message is a pong message.\n  pub fn is_pong(&self) -> bool {\n    matches!(self, Message::Pong(_))\n  }\n\n  /// Indicates whether a message ia s close message.\n  pub fn is_close(&self) -> bool {\n    matches!(*self, Message::Close(_))\n  }\n\n  /// Get the length of the WebSocket message.\n  pub fn len(&self) -> usize {\n    match self {\n      Message::Text(s) => s.len(),\n      Message::Binary(data) => data.len(),\n      Message::Close(data) => data.as_ref().map(|d| d.reason.len()).unwrap_or(0),\n      Message::Ping(data) => data.len(),\n      Message::Pong(data) => data.len(),\n    }\n  }\n\n  /// Returns true if the WebSocket message has no content.\n  /// For example, if the other side of the connection sent an empty string.\n  pub fn is_empty(&self) -> bool {\n    self.len() == 0\n  }\n\n  /// Consume the WebSocket and return it as binary data.\n  pub fn into_data(self) -> Vec<u8> {\n    match self {\n      Message::Text(string) => string.into_bytes(),\n      Message::Binary(data) => data,\n      Message::Close(None) => Vec::new(),\n      Message::Close(Some(frame)) => frame.reason.into_owned().into_bytes(),\n      Message::Ping(data) => data,\n      Message::Pong(data) => data,\n    }\n  }\n\n  /// Attempt to consume the WebSocket message and convert it to a String.\n  pub fn into_text(self) -> Result<String, crate::Error> {\n    match self {\n      Message::Text(string) => Ok(string),\n      Message::Binary(data) => Ok(String::from_utf8(data).map_err(|err| err.utf8_error())?),\n      Message::Close(None) => Ok(String::new()),\n      Message::Close(Some(frame)) => Ok(frame.reason.into_owned()),\n      Message::Ping(data) => Ok(String::from_utf8(data).map_err(|err| err.utf8_error())?),\n      Message::Pong(data) => Ok(String::from_utf8(data).map_err(|err| err.utf8_error())?),\n    }\n  }\n\n  /// Attempt to get a &str from the WebSocket message,\n  /// this will try to convert binary data to utf8.\n  pub fn to_text(&self) -> Result<&str, crate::Error> {\n    match self {\n      Message::Text(s) => Ok(s.as_str()),\n      Message::Binary(data) => Ok(std::str::from_utf8(data)?),\n      Message::Close(None) => Ok(\"\"),\n      Message::Close(Some(ref frame)) => Ok(&frame.reason),\n      Message::Ping(data) => Ok(std::str::from_utf8(data)?),\n      Message::Pong(data) => Ok(std::str::from_utf8(data)?),\n    }\n  }\n}\n\nimpl From<String> for Message {\n  fn from(string: String) -> Self {\n    Message::text(string)\n  }\n}\n\nimpl<'s> From<&'s str> for Message {\n  fn from(string: &'s str) -> Self {\n    Message::text(string)\n  }\n}\n\nimpl<'b> From<&'b [u8]> for Message {\n  fn from(data: &'b [u8]) -> Self {\n    Message::binary(data)\n  }\n}\n\nimpl From<Vec<u8>> for Message {\n  fn from(data: Vec<u8>) -> Self {\n    Message::binary(data)\n  }\n}\n\nimpl From<Message> for Vec<u8> {\n  fn from(message: Message) -> Self {\n    message.into_data()\n  }\n}\n\nimpl std::convert::TryFrom<Message> for String {\n  type Error = crate::Error;\n\n  fn try_from(value: Message) -> std::result::Result<Self, Self::Error> {\n    value.into_text()\n  }\n}\n\nimpl std::fmt::Display for Message {\n  fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {\n    if let Ok(string) = self.to_text() {\n      write!(f, \"{}\", string)\n    } else {\n      write!(f, \"Binary Data<length={}>\", self.len())\n    }\n  }\n}\n\n/// A struct representing the close command.\n#[derive(Debug, Clone, Eq, PartialEq)]\npub struct CloseFrame<'t> {\n  /// The reason as a code.\n  pub code: coding::CloseCode,\n  /// The reason as text string.\n  pub reason: std::borrow::Cow<'t, str>,\n}\n\nimpl CloseFrame<'_> {\n  /// Convert into a owned string.\n  pub fn into_owned(self) -> CloseFrame<'static> {\n    CloseFrame {\n      code: self.code,\n      reason: self.reason.into_owned().into(),\n    }\n  }\n}\n\nimpl std::fmt::Display for CloseFrame<'_> {\n  fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n    write!(f, \"{} ({})\", self.reason, self.code)\n  }\n}\n\npub mod coding {\n  use self::CloseCode::*;\n\n  /// Status code used to indicate why an endpoint is closing the WebSocket connection.\n  #[derive(Debug, Eq, PartialEq, Clone, Copy)]\n  pub enum CloseCode {\n    /// Indicates a normal closure, meaning that the purpose for\n    /// which the connection was established has been fulfilled.\n    Normal,\n    /// Indicates that an endpoint is \"going away\", such as a server\n    /// going down or a browser having navigated away from a page.\n    Away,\n    /// Indicates that an endpoint is terminating the connection due\n    /// to a protocol error.\n    Protocol,\n    /// Indicates that an endpoint is terminating the connection\n    /// because it has received a type of data it cannot accept (e.g., an\n    /// endpoint that understands only text data MAY send this if it\n    /// receives a binary message).\n    Unsupported,\n    /// Indicates that no status code was included in a closing frame. This\n    /// close code makes it possible to use a single method, `on_close` to\n    /// handle even cases where no close code was provided.\n    Status,\n    /// Indicates an abnormal closure. If the abnormal closure was due to an\n    /// error, this close code will not be used. Instead, the `on_error` method\n    /// of the handler will be called with the error. However, if the connection\n    /// is simply dropped, without an error, this close code will be sent to the\n    /// handler.\n    Abnormal,\n    /// Indicates that an endpoint is terminating the connection\n    /// because it has received data within a message that was not\n    /// consistent with the type of the message (e.g., non-UTF-8 \\[RFC3629\\]\n    /// data within a text message).\n    Invalid,\n    /// Indicates that an endpoint is terminating the connection\n    /// because it has received a message that violates its policy.  This\n    /// is a generic status code that can be returned when there is no\n    /// other more suitable status code (e.g., Unsupported or Size) or if there\n    /// is a need to hide specific details about the policy.\n    Policy,\n    /// Indicates that an endpoint is terminating the connection\n    /// because it has received a message that is too big for it to\n    /// process.\n    Size,\n    /// Indicates that an endpoint (client) is terminating the\n    /// connection because it has expected the server to negotiate one or\n    /// more extension, but the server didn't return them in the response\n    /// message of the WebSocket handshake.  The list of extensions that\n    /// are needed should be given as the reason for closing.\n    /// Note that this status code is not used by the server, because it\n    /// can fail the WebSocket handshake instead.\n    Extension,\n    /// Indicates that a server is terminating the connection because\n    /// it encountered an unexpected condition that prevented it from\n    /// fulfilling the request.\n    Error,\n    /// Indicates that the server is restarting. A client may choose to reconnect,\n    /// and if it does, it should use a randomized delay of 5-30 seconds between attempts.\n    Restart,\n    /// Indicates that the server is overloaded and the client should either connect\n    /// to a different IP (when multiple targets exist), or reconnect to the same IP\n    /// when a user has performed an action.\n    Again,\n    #[doc(hidden)]\n    Tls,\n    #[doc(hidden)]\n    Reserved(u16),\n    #[doc(hidden)]\n    Iana(u16),\n    #[doc(hidden)]\n    Library(u16),\n    #[doc(hidden)]\n    Bad(u16),\n  }\n\n  impl CloseCode {\n    /// Check if this CloseCode is allowed.\n    pub fn is_allowed(self) -> bool {\n      !matches!(self, Bad(_) | Reserved(_) | Status | Abnormal | Tls)\n    }\n  }\n\n  impl std::fmt::Display for CloseCode {\n    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {\n      let code: u16 = self.into();\n      write!(f, \"{}\", code)\n    }\n  }\n\n  impl From<CloseCode> for u16 {\n    fn from(code: CloseCode) -> u16 {\n      match code {\n        Normal => 1000,\n        Away => 1001,\n        Protocol => 1002,\n        Unsupported => 1003,\n        Status => 1005,\n        Abnormal => 1006,\n        Invalid => 1007,\n        Policy => 1008,\n        Size => 1009,\n        Extension => 1010,\n        Error => 1011,\n        Restart => 1012,\n        Again => 1013,\n        Tls => 1015,\n        Reserved(code) => code,\n        Iana(code) => code,\n        Library(code) => code,\n        Bad(code) => code,\n      }\n    }\n  }\n\n  impl<'t> From<&'t CloseCode> for u16 {\n    fn from(code: &'t CloseCode) -> u16 {\n      (*code).into()\n    }\n  }\n\n  impl From<u16> for CloseCode {\n    fn from(code: u16) -> CloseCode {\n      match code {\n        1000 => Normal,\n        1001 => Away,\n        1002 => Protocol,\n        1003 => Unsupported,\n        1005 => Status,\n        1006 => Abnormal,\n        1007 => Invalid,\n        1008 => Policy,\n        1009 => Size,\n        1010 => Extension,\n        1011 => Error,\n        1012 => Restart,\n        1013 => Again,\n        1015 => Tls,\n        1..=999 => Bad(code),\n        1016..=2999 => Reserved(code),\n        3000..=3999 => Iana(code),\n        4000..=4999 => Library(code),\n        _ => Bad(code),\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-websocket/src/native.rs",
    "content": "use futures_util::{Sink, Stream, StreamExt};\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse tokio_tungstenite::tungstenite::client::IntoClientRequest;\nuse tokio_tungstenite::tungstenite::http::HeaderMap;\nuse tokio_tungstenite::{\n  tungstenite::{\n    error::*,\n    protocol::{frame::coding::Data, CloseFrame},\n    Message, Result,\n  },\n  MaybeTlsStream,\n};\n\npub async fn connect_async(url: &str, header_map: HeaderMap) -> crate::Result<WebSocketStream> {\n  let mut request = url.into_client_request()?;\n  request.headers_mut().extend(header_map);\n\n  let (inner, _response) = tokio_tungstenite::connect_async(request).await?;\n  let inner = inner.filter_map(to_fut_message as fn(_) -> _);\n  Ok(WebSocketStream { inner })\n}\n\ntype TokioTungsteniteStream =\n  tokio_tungstenite::WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;\ntype FutMessage = futures_util::future::Ready<Option<crate::Result<crate::Message>>>;\npub struct WebSocketStream {\n  inner: futures_util::stream::FilterMap<\n    TokioTungsteniteStream,\n    FutMessage,\n    fn(Result<Message>) -> FutMessage,\n  >,\n}\n\nimpl Stream for WebSocketStream {\n  type Item = crate::Result<crate::Message>;\n\n  fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    Pin::new(&mut self.inner).poll_next(cx)\n  }\n\n  fn size_hint(&self) -> (usize, Option<usize>) {\n    self.inner.size_hint()\n  }\n}\n\nimpl Sink<crate::Message> for WebSocketStream {\n  type Error = crate::Error;\n\n  fn poll_ready(\n    mut self: Pin<&mut Self>,\n    cx: &mut Context<'_>,\n  ) -> Poll<std::result::Result<(), Self::Error>> {\n    Pin::new(&mut self.inner).poll_ready(cx).map_err(Into::into)\n  }\n\n  fn start_send(\n    mut self: Pin<&mut Self>,\n    item: crate::Message,\n  ) -> std::result::Result<(), Self::Error> {\n    Pin::new(&mut self.inner)\n      .start_send(item.into())\n      .map_err(Into::into)\n  }\n\n  fn poll_flush(\n    mut self: Pin<&mut Self>,\n    cx: &mut Context<'_>,\n  ) -> Poll<std::result::Result<(), Self::Error>> {\n    Pin::new(&mut self.inner).poll_flush(cx).map_err(Into::into)\n  }\n\n  fn poll_close(\n    mut self: Pin<&mut Self>,\n    cx: &mut Context<'_>,\n  ) -> Poll<std::result::Result<(), Self::Error>> {\n    Pin::new(&mut self.inner).poll_close(cx).map_err(Into::into)\n  }\n}\n\nfn to_fut_message(msg: Result<Message>) -> FutMessage {\n  fn inner(msg: Result<Message>) -> Option<crate::Result<crate::Message>> {\n    let msg = match msg {\n      Ok(msg) => match msg {\n        Message::Text(inner) => Ok(crate::Message::Text(inner)),\n        Message::Binary(inner) => Ok(crate::Message::Binary(inner)),\n        Message::Close(inner) => Ok(crate::Message::Close(inner.map(Into::into))),\n        Message::Pong(inner) => Ok(crate::Message::Pong(inner)),\n        Message::Ping(inner) => Ok(crate::Message::Ping(inner)),\n        Message::Frame(_) => return None,\n      },\n      Err(err) => Err(crate::Error::from(err)),\n    };\n    Some(msg)\n  }\n  futures_util::future::ready(inner(msg))\n}\n\nimpl<'a> From<CloseFrame<'a>> for crate::message::CloseFrame<'a> {\n  fn from(close_frame: CloseFrame<'a>) -> Self {\n    crate::message::CloseFrame {\n      code: u16::from(close_frame.code).into(),\n      reason: close_frame.reason,\n    }\n  }\n}\n\nimpl<'a> From<crate::message::CloseFrame<'a>> for CloseFrame<'a> {\n  fn from(close_frame: crate::message::CloseFrame<'a>) -> Self {\n    CloseFrame {\n      code: u16::from(close_frame.code).into(),\n      reason: close_frame.reason,\n    }\n  }\n}\n\nimpl From<Message> for crate::Message {\n  fn from(msg: Message) -> Self {\n    match msg {\n      Message::Text(inner) => crate::Message::Text(inner),\n      Message::Binary(inner) => crate::Message::Binary(inner),\n      Message::Close(inner) => crate::Message::Close(inner.map(Into::into)),\n      Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {\n        unreachable!(\"Unsendable via interface.\")\n      },\n    }\n  }\n}\n\nimpl From<crate::Message> for Message {\n  fn from(msg: crate::Message) -> Self {\n    match msg {\n      crate::Message::Text(inner) => Message::Text(inner),\n      crate::Message::Binary(inner) => Message::Binary(inner),\n      crate::Message::Close(inner) => Message::Close(inner.map(Into::into)),\n      crate::Message::Ping(data) => Message::Ping(data),\n      crate::Message::Pong(data) => Message::Pong(data),\n    }\n  }\n}\n\nimpl From<Error> for crate::Error {\n  fn from(err: Error) -> Self {\n    match err {\n      Error::ConnectionClosed => crate::Error::ConnectionClosed,\n      Error::AlreadyClosed => crate::Error::AlreadyClosed,\n      Error::Io(inner) => crate::Error::Io(inner),\n      Error::Tls(inner) => crate::Error::Tls(inner),\n      Error::Capacity(inner) => crate::Error::Capacity(inner.into()),\n      Error::Protocol(inner) => crate::Error::Protocol(inner.into()),\n      Error::WriteBufferFull(inner) => crate::Error::WriteBufferFull(inner.into()),\n      Error::Utf8 => crate::Error::Utf8,\n      Error::AttackAttempt => crate::Error::AttackAttempt,\n      Error::Url(inner) => crate::Error::Url(inner.into()),\n      Error::Http(inner) => crate::Error::Http(inner.into()),\n      Error::HttpFormat(inner) => crate::Error::HttpFormat(inner),\n    }\n  }\n}\n\nimpl From<CapacityError> for crate::error::CapacityError {\n  fn from(err: CapacityError) -> Self {\n    match err {\n      CapacityError::TooManyHeaders => crate::error::CapacityError::TooManyHeaders,\n      CapacityError::MessageTooLong { size, max_size } => {\n        crate::error::CapacityError::MessageTooLong { size, max_size }\n      },\n    }\n  }\n}\n\nimpl From<UrlError> for crate::error::UrlError {\n  fn from(err: UrlError) -> Self {\n    match err {\n      UrlError::TlsFeatureNotEnabled => crate::error::UrlError::TlsFeatureNotEnabled,\n      UrlError::NoHostName => crate::error::UrlError::NoHostName,\n      UrlError::UnableToConnect(inner) => crate::error::UrlError::UnableToConnect(inner),\n      UrlError::UnsupportedUrlScheme => crate::error::UrlError::UnsupportedUrlScheme,\n      UrlError::EmptyHostName => crate::error::UrlError::EmptyHostName,\n      UrlError::NoPathOrQuery => crate::error::UrlError::NoPathOrQuery,\n    }\n  }\n}\n\nimpl From<ProtocolError> for crate::error::ProtocolError {\n  fn from(err: ProtocolError) -> Self {\n    match err {\n      ProtocolError::WrongHttpMethod => crate::error::ProtocolError::WrongHttpMethod,\n      ProtocolError::WrongHttpVersion => crate::error::ProtocolError::WrongHttpVersion,\n      ProtocolError::MissingConnectionUpgradeHeader => {\n        crate::error::ProtocolError::MissingConnectionUpgradeHeader\n      },\n      ProtocolError::MissingUpgradeWebSocketHeader => {\n        crate::error::ProtocolError::MissingUpgradeWebSocketHeader\n      },\n      ProtocolError::MissingSecWebSocketVersionHeader => {\n        crate::error::ProtocolError::MissingSecWebSocketVersionHeader\n      },\n      ProtocolError::MissingSecWebSocketKey => crate::error::ProtocolError::MissingSecWebSocketKey,\n      ProtocolError::SecWebSocketAcceptKeyMismatch => {\n        crate::error::ProtocolError::SecWebSocketAcceptKeyMismatch\n      },\n      ProtocolError::JunkAfterRequest => crate::error::ProtocolError::JunkAfterRequest,\n      ProtocolError::CustomResponseSuccessful => {\n        crate::error::ProtocolError::CustomResponseSuccessful\n      },\n      ProtocolError::InvalidHeader(header_name) => {\n        crate::error::ProtocolError::InvalidHeader(header_name)\n      },\n      ProtocolError::HandshakeIncomplete => crate::error::ProtocolError::HandshakeIncomplete,\n      ProtocolError::HttparseError(inner) => crate::error::ProtocolError::HttparseError(inner),\n      ProtocolError::SendAfterClosing => crate::error::ProtocolError::SendAfterClosing,\n      ProtocolError::ReceivedAfterClosing => crate::error::ProtocolError::ReceivedAfterClosing,\n      ProtocolError::NonZeroReservedBits => crate::error::ProtocolError::NonZeroReservedBits,\n      ProtocolError::UnmaskedFrameFromClient => {\n        crate::error::ProtocolError::UnmaskedFrameFromClient\n      },\n      ProtocolError::MaskedFrameFromServer => crate::error::ProtocolError::MaskedFrameFromServer,\n      ProtocolError::FragmentedControlFrame => crate::error::ProtocolError::FragmentedControlFrame,\n      ProtocolError::ControlFrameTooBig => crate::error::ProtocolError::ControlFrameTooBig,\n      ProtocolError::UnknownControlFrameType(inner) => {\n        crate::error::ProtocolError::UnknownControlFrameType(inner)\n      },\n      ProtocolError::UnknownDataFrameType(inner) => {\n        crate::error::ProtocolError::UnknownDataFrameType(inner)\n      },\n      ProtocolError::UnexpectedContinueFrame => {\n        crate::error::ProtocolError::UnexpectedContinueFrame\n      },\n      ProtocolError::ExpectedFragment(inner) => {\n        crate::error::ProtocolError::ExpectedFragment(inner.into())\n      },\n      ProtocolError::ResetWithoutClosingHandshake => {\n        crate::error::ProtocolError::ResetWithoutClosingHandshake\n      },\n      ProtocolError::InvalidOpcode(inner) => crate::error::ProtocolError::InvalidOpcode(inner),\n      ProtocolError::InvalidCloseSequence => crate::error::ProtocolError::InvalidCloseSequence,\n    }\n  }\n}\n\nimpl From<Data> for crate::error::Data {\n  fn from(data: Data) -> Self {\n    match data {\n      Data::Continue => crate::error::Data::Continue,\n      Data::Text => crate::error::Data::Text,\n      Data::Binary => crate::error::Data::Binary,\n      Data::Reserved(inner) => crate::error::Data::Reserved(inner),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/client-websocket/src/web.rs",
    "content": "use http::HeaderMap;\nuse percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};\nuse std::{cell::RefCell, collections::VecDeque, rc::Rc, task::Waker};\nuse wasm_bindgen::{closure::Closure, JsCast};\nuse web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};\n\npub async fn connect_async(url: &str, header_map: HeaderMap) -> crate::Result<WebSocketStream> {\n  WebSocketStream::new(url, header_map).await\n}\n\npub struct WebSocketStream {\n  inner: WebSocket,\n  queue: Rc<RefCell<VecDeque<crate::Result<crate::Message>>>>,\n  waker: Rc<RefCell<Option<Waker>>>,\n  _on_message_callback: Closure<dyn FnMut(MessageEvent)>,\n  _on_error_callback: Closure<dyn FnMut(ErrorEvent)>,\n  _on_close_callback: Closure<dyn FnMut(CloseEvent)>,\n}\n\nimpl WebSocketStream {\n  async fn new(url: &str, headers: HeaderMap) -> crate::Result<Self> {\n    let query_string = header_map_to_query_string(&headers);\n    // Construct the full WebSocket URL with query parameters\n    let conn_url = format!(\"{}?{}\", url, query_string);\n    match web_sys::WebSocket::new(&conn_url) {\n      Err(_err) => Err(crate::Error::Url(\n        crate::error::UrlError::UnsupportedUrlScheme,\n      )),\n      Ok(ws) => {\n        ws.set_binary_type(web_sys::BinaryType::Arraybuffer);\n\n        let (open_sx, open_rx) = futures_channel::oneshot::channel();\n        let on_open_callback = {\n          let mut open_sx = Some(open_sx);\n          Closure::wrap(Box::new(move |_event| {\n            open_sx.take().map(|open_sx| open_sx.send(()));\n          }) as Box<dyn FnMut(web_sys::Event)>)\n        };\n        ws.set_onopen(Some(on_open_callback.as_ref().unchecked_ref()));\n\n        let (err_sx, err_rx) = futures_channel::oneshot::channel();\n        let on_error_callback = {\n          let mut err_sx = Some(err_sx);\n          Closure::wrap(Box::new(move |_error_event| {\n            err_sx.take().map(|err_sx| err_sx.send(()));\n          }) as Box<dyn FnMut(ErrorEvent)>)\n        };\n        ws.set_onerror(Some(on_error_callback.as_ref().unchecked_ref()));\n\n        let result = futures_util::future::select(open_rx, err_rx).await;\n        ws.set_onopen(None);\n        ws.set_onerror(None);\n        let ws = match result {\n          futures_util::future::Either::Left((_, _)) => Ok(ws),\n          futures_util::future::Either::Right((_, _)) => Err(crate::Error::ConnectionClosed),\n        }?;\n\n        let waker = Rc::new(RefCell::new(Option::<Waker>::None));\n        let queue = Rc::new(RefCell::new(VecDeque::new()));\n        let on_message_callback = {\n          let waker = Rc::clone(&waker);\n          let queue = Rc::clone(&queue);\n          Closure::wrap(Box::new(move |event: MessageEvent| {\n            let payload = std::convert::TryFrom::try_from(event);\n            queue.borrow_mut().push_back(payload);\n            if let Some(waker) = waker.borrow_mut().take() {\n              waker.wake();\n            }\n          }) as Box<dyn FnMut(MessageEvent)>)\n        };\n        ws.set_onmessage(Some(on_message_callback.as_ref().unchecked_ref()));\n\n        let on_error_callback = {\n          let waker = Rc::clone(&waker);\n          let queue = Rc::clone(&queue);\n          Closure::wrap(Box::new(move |_error_event| {\n            queue\n              .borrow_mut()\n              .push_back(Err(crate::Error::ConnectionClosed));\n            if let Some(waker) = waker.borrow_mut().take() {\n              waker.wake();\n            }\n          }) as Box<dyn FnMut(ErrorEvent)>)\n        };\n        ws.set_onerror(Some(on_error_callback.as_ref().unchecked_ref()));\n\n        let on_close_callback = {\n          let waker = Rc::clone(&waker);\n          let queue = Rc::clone(&queue);\n          Closure::wrap(Box::new(move |event: CloseEvent| {\n            queue.borrow_mut().push_back(Ok(crate::Message::Close(Some(\n              crate::message::CloseFrame {\n                code: event.code().into(),\n                reason: event.reason().into(),\n              },\n            ))));\n            if let Some(waker) = waker.borrow_mut().take() {\n              waker.wake();\n            }\n          }) as Box<dyn FnMut(CloseEvent)>)\n        };\n        ws.set_onclose(Some(on_close_callback.as_ref().unchecked_ref()));\n\n        Ok(Self {\n          inner: ws,\n          queue,\n          waker,\n          _on_message_callback: on_message_callback,\n          _on_error_callback: on_error_callback,\n          _on_close_callback: on_close_callback,\n        })\n      },\n    }\n  }\n}\n\nimpl Drop for WebSocketStream {\n  fn drop(&mut self) {\n    let _r = self.inner.close();\n    self.inner.set_onmessage(None);\n    self.inner.set_onclose(None);\n    self.inner.set_onerror(None);\n  }\n}\n\nenum ReadyState {\n  Closed,\n  Closing,\n  Connecting,\n  Open,\n}\n\nimpl std::convert::TryFrom<u16> for ReadyState {\n  type Error = ();\n\n  fn try_from(value: u16) -> Result<Self, Self::Error> {\n    match value {\n      web_sys::WebSocket::CLOSED => Ok(Self::Closed),\n      web_sys::WebSocket::CLOSING => Ok(Self::Closing),\n      web_sys::WebSocket::OPEN => Ok(Self::Open),\n      web_sys::WebSocket::CONNECTING => Ok(Self::Connecting),\n      _ => Err(()),\n    }\n  }\n}\n\nmod stream {\n  use super::ReadyState;\n  use std::pin::Pin;\n  use std::task::{Context, Poll};\n\n  impl futures_util::Stream for super::WebSocketStream {\n    type Item = crate::Result<crate::Message>;\n\n    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n      if self.queue.borrow().is_empty() {\n        *self.waker.borrow_mut() = Some(cx.waker().clone());\n\n        match std::convert::TryFrom::try_from(self.inner.ready_state()) {\n          Ok(ReadyState::Open) => Poll::Pending,\n          _ => None.into(),\n        }\n      } else {\n        self.queue.borrow_mut().pop_front().into()\n      }\n    }\n  }\n\n  impl futures_util::Sink<crate::Message> for super::WebSocketStream {\n    type Error = crate::Error;\n\n    fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n      match std::convert::TryFrom::try_from(self.inner.ready_state()) {\n        Ok(ReadyState::Open) => Ok(()).into(),\n        _ => Err(crate::Error::ConnectionClosed).into(),\n      }\n    }\n\n    fn start_send(self: Pin<&mut Self>, item: crate::Message) -> Result<(), Self::Error> {\n      match std::convert::TryFrom::try_from(self.inner.ready_state()) {\n        Ok(ReadyState::Open) => {\n          match item {\n            crate::Message::Text(text) => self\n              .inner\n              .send_with_str(&text)\n              .map_err(|_| crate::Error::Utf8)?,\n            crate::Message::Binary(bin) => self\n              .inner\n              .send_with_u8_array(&bin)\n              .map_err(|_| crate::Error::Utf8)?,\n            crate::Message::Close(frame) => match frame {\n              None => self\n                .inner\n                .close()\n                .map_err(|_| crate::Error::AlreadyClosed)?,\n              Some(frame) => self\n                .inner\n                .close_with_code_and_reason(frame.code.into(), &frame.reason)\n                .map_err(|_| crate::Error::AlreadyClosed)?,\n            },\n            crate::Message::Ping(data) => self\n              .inner\n              .send_with_u8_array(&data)\n              .map_err(|_| crate::Error::Utf8)?,\n            crate::Message::Pong(data) => self\n              .inner\n              .send_with_u8_array(&data)\n              .map_err(|_| crate::Error::Utf8)?,\n          }\n          Ok(())\n        },\n        _ => Err(crate::Error::ConnectionClosed),\n      }\n    }\n\n    fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n      Ok(()).into()\n    }\n\n    fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n      self\n        .inner\n        .close()\n        .map_err(|_| crate::Error::AlreadyClosed)?;\n      Ok(()).into()\n    }\n  }\n}\n\nimpl std::convert::TryFrom<web_sys::MessageEvent> for crate::Message {\n  type Error = crate::Error;\n\n  fn try_from(event: MessageEvent) -> Result<Self, Self::Error> {\n    match event.data() {\n      payload if payload.is_instance_of::<js_sys::ArrayBuffer>() => {\n        let buffer = js_sys::Uint8Array::new(payload.unchecked_ref());\n        let mut v = vec![0; buffer.length() as usize];\n        buffer.copy_to(&mut v);\n        Ok(crate::Message::Binary(v))\n      },\n      payload if payload.is_string() => match payload.as_string() {\n        Some(text) => Ok(crate::Message::Text(text)),\n        None => Err(crate::Error::Utf8),\n      },\n      payload if payload.is_instance_of::<web_sys::Blob>() => {\n        Err(crate::Error::BlobFormatUnsupported)\n      },\n      _ => Err(crate::Error::UnknownFormat),\n    }\n  }\n}\n\nfn header_map_to_query_string(headers: &HeaderMap) -> String {\n  headers\n    .iter()\n    .filter_map(|(name, value)| {\n      // Convert the header name and value to string\n      let name = name.as_str();\n      let value = value.to_str().ok()?;\n      Some((name, value))\n    })\n    .map(|(name, value)| {\n      // Percent-encode the name and value to ensure they are URL-safe\n      let name_encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string();\n      let value_encoded = utf8_percent_encode(value, NON_ALPHANUMERIC).to_string();\n      format!(\"{}={}\", name_encoded, value_encoded)\n    })\n    .collect::<Vec<_>>()\n    .join(\"&\")\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/.gitignore",
    "content": "src/realtime_proto.rs"
  },
  {
    "path": "libs/collab-rt-entity/Cargo.toml",
    "content": "[package]\nname = \"collab-rt-entity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\ncollab = { workspace = true }\ncollab-entity = { workspace = true }\nserde.workspace = true\nbytes = { version = \"1.5\", features = [\"serde\"] }\nanyhow.workspace = true\nactix = { version = \"0.13\", optional = true }\nbincode.workspace = true\ntokio-tungstenite = { version = \"0.20.1\", optional = true }\nprost.workspace = true\ndatabase-entity.workspace = true\nyrs.workspace = true\ncollab-rt-protocol.workspace = true\nserde_repr = \"0.1\"\nchrono = \"0.4\"\n\n[build-dependencies]\nprotoc-bin-vendored = { version = \"3.0\" }\nprost-build = \"0.12.3\"\n\n[features]\nactix_message = [\"actix\"]\ntungstenite = [\"tokio-tungstenite\"]\n"
  },
  {
    "path": "libs/collab-rt-entity/build.rs",
    "content": "use std::process::Command;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n  // If the `PROTOC` environment variable is set, don't use vendored `protoc`\n  std::env::var(\"PROTOC\").map(|_| ()).unwrap_or_else(|_| {\n    let protoc_path = protoc_bin_vendored::protoc_bin_path().expect(\"protoc bin path\");\n    let protoc_path_str = protoc_path.to_str().expect(\"protoc path to str\");\n\n    // Set the `PROTOC` environment variable to the path of the `protoc` binary.\n    std::env::set_var(\"PROTOC\", protoc_path_str);\n  });\n\n  let proto_files = vec![\"proto/realtime.proto\", \"proto/collab.proto\"];\n  for proto_file in &proto_files {\n    println!(\"cargo:rerun-if-changed={}\", proto_file);\n  }\n\n  prost_build::Config::new()\n    .out_dir(\"src/\")\n    .compile_protos(&proto_files, &[\"proto/\"])?;\n\n  // Run cargo fmt to format the code\n  Command::new(\"cargo\").arg(\"fmt\").status()?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/proto/collab.proto",
    "content": "syntax = \"proto3\";\n\npackage collab_proto;\n\nenum PayloadCompressionType {\n    NONE = 0;\n    ZSTD = 1;\n}\n\nmessage CollabDocStateParams {\n    string object_id = 1;\n    int32 collab_type = 2;\n    PayloadCompressionType compression = 3;\n    bytes sv = 4;\n    bytes doc_state = 5;\n}"
  },
  {
    "path": "libs/collab-rt-entity/proto/realtime.proto",
    "content": "syntax = \"proto3\";\npackage realtime_proto;\n\nmessage HttpRealtimeMessage {\n  string device_id = 1;\n  bytes payload = 2;\n}"
  },
  {
    "path": "libs/collab-rt-entity/src/client_message.rs",
    "content": "use crate::message::RealtimeMessage;\nuse crate::server_message::ServerInit;\nuse crate::{CollabMessage, MessageByObjectId, MsgId};\nuse anyhow::{anyhow, Error};\nuse bytes::Bytes;\nuse collab::core::origin::CollabOrigin;\nuse collab_entity::CollabType;\nuse collab_rt_protocol::{Message, MessageReader, SyncMessage};\nuse serde::{Deserialize, Serialize};\nuse std::cmp::Ordering;\nuse std::fmt::{Display, Formatter};\nuse std::hash::{Hash, Hasher};\nuse yrs::merge_updates_v1;\nuse yrs::updates::decoder::DecoderV1;\nuse yrs::updates::encoder::{Encode, Encoder, EncoderV1};\n\npub trait SinkMessage: Clone + Send + Sync + 'static + Ord + Display {\n  fn payload_size(&self) -> usize;\n  fn mergeable(&self) -> bool;\n  fn merge(&mut self, other: &Self, maximum_payload_size: &usize) -> Result<bool, Error>;\n  fn is_client_init_sync(&self) -> bool;\n  fn is_server_init_sync(&self) -> bool;\n  fn is_update_sync(&self) -> bool;\n  fn is_awareness_sync(&self) -> bool;\n  fn is_ping_sync(&self) -> bool;\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub enum ClientCollabMessage {\n  ClientInitSync { data: InitSync },\n  ClientUpdateSync { data: UpdateSync },\n  ServerInitSync(ServerInit),\n  ClientAwarenessSync(UpdateSync),\n  ClientCollabStateCheck(CollabStateCheck),\n}\n\nimpl ClientCollabMessage {\n  pub fn new_init_sync(data: InitSync) -> Self {\n    Self::ClientInitSync { data }\n  }\n\n  pub fn new_update_sync(data: UpdateSync) -> Self {\n    Self::ClientUpdateSync { data }\n  }\n\n  pub fn new_server_init_sync(data: ServerInit) -> Self {\n    Self::ServerInitSync(data)\n  }\n\n  pub fn new_awareness_sync(data: UpdateSync) -> Self {\n    Self::ClientAwarenessSync(data)\n  }\n\n  pub fn size(&self) -> usize {\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => data.payload.len(),\n      ClientCollabMessage::ClientUpdateSync { data, .. } => data.payload.len(),\n      ClientCollabMessage::ServerInitSync(msg) => msg.payload.len(),\n      ClientCollabMessage::ClientAwarenessSync(data) => data.payload.len(),\n      ClientCollabMessage::ClientCollabStateCheck(_) => 0,\n    }\n  }\n  pub fn object_id(&self) -> &str {\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => &data.object_id,\n      ClientCollabMessage::ClientUpdateSync { data, .. } => &data.object_id,\n      ClientCollabMessage::ServerInitSync(msg) => &msg.object_id,\n      ClientCollabMessage::ClientAwarenessSync(data) => &data.object_id,\n      ClientCollabMessage::ClientCollabStateCheck(data) => &data.object_id,\n    }\n  }\n\n  pub fn origin(&self) -> &CollabOrigin {\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => &data.origin,\n      ClientCollabMessage::ClientUpdateSync { data, .. } => &data.origin,\n      ClientCollabMessage::ServerInitSync(msg) => &msg.origin,\n      ClientCollabMessage::ClientAwarenessSync(data) => &data.origin,\n      ClientCollabMessage::ClientCollabStateCheck(data) => &data.origin,\n    }\n  }\n  pub fn payload(&self) -> &Bytes {\n    static EMPTY_BYTES: Bytes = Bytes::from_static(b\"\");\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => &data.payload,\n      ClientCollabMessage::ClientUpdateSync { data, .. } => &data.payload,\n      ClientCollabMessage::ServerInitSync(msg) => &msg.payload,\n      ClientCollabMessage::ClientAwarenessSync(data) => &data.payload,\n      ClientCollabMessage::ClientCollabStateCheck(_data) => &EMPTY_BYTES,\n    }\n  }\n  pub fn device_id(&self) -> Option<String> {\n    match self.origin() {\n      CollabOrigin::Client(origin) => Some(origin.device_id.clone()),\n      _ => None,\n    }\n  }\n\n  pub fn msg_id(&self) -> MsgId {\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => data.msg_id,\n      ClientCollabMessage::ClientUpdateSync { data, .. } => data.msg_id,\n      ClientCollabMessage::ServerInitSync(value) => value.msg_id,\n      ClientCollabMessage::ClientAwarenessSync(data) => data.msg_id,\n      ClientCollabMessage::ClientCollabStateCheck(data) => data.msg_id,\n    }\n  }\n\n  pub fn is_init_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientInitSync { .. })\n  }\n}\n\nimpl Display for ClientCollabMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ClientCollabMessage::ClientInitSync { data, .. } => Display::fmt(&data, f),\n      ClientCollabMessage::ClientUpdateSync { data, .. } => Display::fmt(&data, f),\n      ClientCollabMessage::ServerInitSync(value) => Display::fmt(&value, f),\n      ClientCollabMessage::ClientAwarenessSync(data) => f.write_fmt(format_args!(\n        \"awareness: [uid:{}|oid:{}|msg_id:{}|len:{}]\",\n        data.origin.client_user_id().unwrap_or(0),\n        data.object_id,\n        data.msg_id,\n        data.payload.len(),\n      )),\n      ClientCollabMessage::ClientCollabStateCheck(data) => Display::fmt(data, f),\n    }\n  }\n}\n\nimpl TryFrom<CollabMessage> for ClientCollabMessage {\n  type Error = Error;\n\n  fn try_from(value: CollabMessage) -> Result<Self, Self::Error> {\n    match value {\n      CollabMessage::ClientInitSync(msg) => Ok(ClientCollabMessage::ClientInitSync { data: msg }),\n      CollabMessage::ClientUpdateSync(msg) => {\n        Ok(ClientCollabMessage::ClientUpdateSync { data: msg })\n      },\n      CollabMessage::ServerInitSync(msg) => Ok(ClientCollabMessage::ServerInitSync(msg)),\n      _ => Err(anyhow!(\n        \"Can't convert to ClientCollabMessage for value:{}\",\n        value\n      )),\n    }\n  }\n}\n\nimpl From<ClientCollabMessage> for RealtimeMessage {\n  fn from(msg: ClientCollabMessage) -> Self {\n    let object_id = msg.object_id().to_string();\n    let message = MessageByObjectId::new_with_message(object_id, vec![msg]);\n    Self::ClientCollabV2(message)\n  }\n}\n\nimpl SinkMessage for ClientCollabMessage {\n  fn payload_size(&self) -> usize {\n    self.size()\n  }\n\n  fn mergeable(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientUpdateSync { .. })\n  }\n\n  fn merge(&mut self, other: &Self, maximum_payload_size: &usize) -> Result<bool, Error> {\n    match (self, other) {\n      (\n        ClientCollabMessage::ClientUpdateSync { data, .. },\n        ClientCollabMessage::ClientUpdateSync { data: other, .. },\n      ) => {\n        if &data.payload.len() > maximum_payload_size {\n          Ok(false)\n        } else {\n          data.merge_payload(other)\n        }\n      },\n      _ => Ok(false),\n    }\n  }\n\n  fn is_client_init_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientInitSync { .. })\n  }\n\n  fn is_server_init_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ServerInitSync { .. })\n  }\n\n  fn is_update_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientUpdateSync { .. })\n  }\n\n  fn is_awareness_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientAwarenessSync { .. })\n  }\n\n  fn is_ping_sync(&self) -> bool {\n    matches!(self, ClientCollabMessage::ClientCollabStateCheck { .. })\n  }\n}\n\nimpl Hash for ClientCollabMessage {\n  fn hash<H: Hasher>(&self, state: &mut H) {\n    self.origin().hash(state);\n    self.msg_id().hash(state);\n    self.object_id().hash(state);\n  }\n}\n\nimpl Eq for ClientCollabMessage {}\n\nimpl PartialEq for ClientCollabMessage {\n  fn eq(&self, other: &Self) -> bool {\n    self.msg_id() == other.msg_id()\n      && self.object_id() == other.object_id()\n      && self.origin() == other.origin()\n  }\n}\n\nimpl PartialOrd for ClientCollabMessage {\n  fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n    Some(self.cmp(other))\n  }\n}\n\nimpl Ord for ClientCollabMessage {\n  fn cmp(&self, other: &Self) -> Ordering {\n    match (&self, &other) {\n      (ClientCollabMessage::ClientInitSync { .. }, ClientCollabMessage::ClientInitSync { .. }) => {\n        Ordering::Equal\n      },\n      (ClientCollabMessage::ClientInitSync { .. }, _) => Ordering::Greater,\n      (_, ClientCollabMessage::ClientInitSync { .. }) => Ordering::Less,\n      (ClientCollabMessage::ServerInitSync(_left), ClientCollabMessage::ServerInitSync(_right)) => {\n        Ordering::Equal\n      },\n      (ClientCollabMessage::ServerInitSync { .. }, _) => Ordering::Greater,\n      (_, ClientCollabMessage::ServerInitSync { .. }) => Ordering::Less,\n      _ => self.msg_id().cmp(&other.msg_id()).reverse(),\n    }\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct InitSync {\n  pub origin: CollabOrigin,\n  pub object_id: String,\n  pub collab_type: CollabType,\n  pub workspace_id: String,\n  pub msg_id: MsgId,\n  pub payload: Bytes,\n}\n\nimpl InitSync {\n  pub fn new(\n    origin: CollabOrigin,\n    object_id: String,\n    collab_type: CollabType,\n    workspace_id: String,\n    msg_id: MsgId,\n    payload: Vec<u8>,\n  ) -> Self {\n    let payload = Bytes::from(payload);\n    Self {\n      origin,\n      object_id,\n      collab_type,\n      workspace_id,\n      msg_id,\n      payload,\n    }\n  }\n}\n\nimpl Display for InitSync {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"client init: [uid:{}|oid:{}|msg_id:{}|len:{}]\",\n      self.origin.client_user_id().unwrap_or(0),\n      self.object_id,\n      self.msg_id,\n      self.payload.len(),\n    ))\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct UpdateSync {\n  pub origin: CollabOrigin,\n  pub object_id: String,\n  pub msg_id: MsgId,\n  /// \"The payload is encoded using the `EncoderV1` with the `Message` struct.\n  /// Message::Sync(SyncMessage::Update(update)).encode_v1()\n  ///\n  /// we can using the `MessageReader` to decode the payload\n  /// ```text\n  ///   let mut decoder = DecoderV1::new(Cursor::new(payload));\n  ///   let reader = MessageReader::new(&mut decoder);\n  ///   for message in reader {\n  ///    ...\n  ///   }\n  /// ```\n  ///\n  pub payload: Bytes,\n}\n\nimpl UpdateSync {\n  pub fn new(origin: CollabOrigin, object_id: String, payload: Vec<u8>, msg_id: MsgId) -> Self {\n    Self {\n      origin,\n      object_id,\n      payload: Bytes::from(payload),\n      msg_id,\n    }\n  }\n\n  pub fn merge_payload(&mut self, other: &Self) -> Result<bool, Error> {\n    // TODO(nathan): optimize the merge process\n    if let (\n      Some(Message::Sync(SyncMessage::Update(left))),\n      Some(Message::Sync(SyncMessage::Update(right))),\n    ) = (self.as_update(), other.as_update())\n    {\n      let update = merge_updates_v1([left, right])?;\n      let msg = Message::Sync(SyncMessage::Update(update));\n      let mut encoder = EncoderV1::new();\n      msg.encode(&mut encoder);\n      self.payload = Bytes::from(encoder.to_vec());\n      Ok(true)\n    } else {\n      Ok(false)\n    }\n  }\n\n  fn as_update(&self) -> Option<Message> {\n    let mut decoder = DecoderV1::from(self.payload.as_ref());\n    let mut reader = MessageReader::new(&mut decoder);\n    reader.next()?.ok()\n  }\n}\n\nimpl Display for UpdateSync {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"update: [uid:{}|oid:{}|msg_id:{:?}|len:{}]\",\n      self.origin.client_user_id().unwrap_or(0),\n      self.object_id,\n      self.msg_id,\n      self.payload.len(),\n    ))\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct CollabStateCheck {\n  pub origin: CollabOrigin,\n  pub object_id: String,\n  pub msg_id: MsgId,\n}\n\nimpl Display for CollabStateCheck {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"ping: [uid:{}|oid:{}|msg_id:{:?}]\",\n      self.origin.client_user_id().unwrap_or(0),\n      self.object_id,\n      self.msg_id,\n    ))\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/src/collab_proto.rs",
    "content": "// This file is @generated by prost-build.\n#[allow(clippy::derive_partial_eq_without_eq)]\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct CollabDocStateParams {\n  #[prost(string, tag = \"1\")]\n  pub object_id: ::prost::alloc::string::String,\n  #[prost(int32, tag = \"2\")]\n  pub collab_type: i32,\n  #[prost(enumeration = \"PayloadCompressionType\", tag = \"3\")]\n  pub compression: i32,\n  #[prost(bytes = \"vec\", tag = \"4\")]\n  pub sv: ::prost::alloc::vec::Vec<u8>,\n  #[prost(bytes = \"vec\", tag = \"5\")]\n  pub doc_state: ::prost::alloc::vec::Vec<u8>,\n}\n#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]\n#[repr(i32)]\npub enum PayloadCompressionType {\n  None = 0,\n  Zstd = 1,\n}\nimpl PayloadCompressionType {\n  /// String value of the enum field names used in the ProtoBuf definition.\n  ///\n  /// The values are not transformed in any way and thus are considered stable\n  /// (if the ProtoBuf definition does not change) and safe for programmatic use.\n  pub fn as_str_name(&self) -> &'static str {\n    match self {\n      PayloadCompressionType::None => \"NONE\",\n      PayloadCompressionType::Zstd => \"ZSTD\",\n    }\n  }\n  /// Creates an enum from field names used in the ProtoBuf definition.\n  pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {\n    match value {\n      \"NONE\" => Some(Self::None),\n      \"ZSTD\" => Some(Self::Zstd),\n      _ => None,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/src/lib.rs",
    "content": "mod message;\npub mod user;\n\nmod client_message;\n// If the realtime_proto not exist, the following code will be generated:\n// ```shell\n//  cd libs/collab-rt-entity\n//  cargo clean\n//  cargo build\n// ```\npub mod collab_proto;\npub mod realtime_proto;\nmod server_message;\n\npub use client_message::*;\npub use message::*;\npub use realtime_proto::*;\npub use server_message::*;\n"
  },
  {
    "path": "libs/collab-rt-entity/src/message.rs",
    "content": "use anyhow::{anyhow, Error};\nuse bincode::{DefaultOptions, Options};\nuse std::collections::HashMap;\n\nuse crate::client_message::ClientCollabMessage;\nuse crate::server_message::ServerCollabMessage;\nuse crate::user::UserMessage;\nuse crate::{AwarenessSync, BroadcastSync, CollabAck, InitSync, ServerInit, UpdateSync};\nuse bytes::Bytes;\nuse collab::core::origin::CollabOrigin;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::{Display, Formatter};\nuse std::ops::{Deref, DerefMut};\n\npub const MAXIMUM_REALTIME_MESSAGE_SIZE: usize = 10 * 1024 * 1024; // 10 MB\n\n/// Get the maximum realtime message size from environment variable or use default.\n///\n/// Reads from the `APPFLOWY_REALTIME_MESSAGE_SIZE` environment variable.\n/// If not set or invalid, falls back to `MAXIMUM_REALTIME_MESSAGE_SIZE`.\npub fn max_sync_message_size() -> usize {\n  std::env::var(\"APPFLOWY_REALTIME_MESSAGE_SIZE\")\n    .ok()\n    .and_then(|s| s.parse().ok())\n    .unwrap_or(MAXIMUM_REALTIME_MESSAGE_SIZE)\n}\n\n#[derive(Debug, Default, Clone, Serialize, Deserialize)]\npub struct MessageByObjectId(pub HashMap<String, Vec<ClientCollabMessage>>);\nimpl MessageByObjectId {\n  pub fn new_with_message(object_id: String, messages: Vec<ClientCollabMessage>) -> Self {\n    let mut map = HashMap::with_capacity(1);\n    map.insert(object_id, messages);\n    Self(map)\n  }\n\n  pub fn into_inner(self) -> HashMap<String, Vec<ClientCollabMessage>> {\n    self.0\n  }\n}\nimpl Deref for MessageByObjectId {\n  type Target = HashMap<String, Vec<ClientCollabMessage>>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl DerefMut for MessageByObjectId {\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    &mut self.0\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"actix_message\",\n  derive(actix::Message),\n  rtype(result = \"()\")\n)]\npub enum RealtimeMessage {\n  Collab(CollabMessage), // Deprecated\n  User(UserMessage),\n  System(SystemMessage),\n  ClientCollabV1(Vec<ClientCollabMessage>), // Deprecated\n  ClientCollabV2(MessageByObjectId),\n  ServerCollabV1(Vec<ServerCollabMessage>),\n}\n\nimpl RealtimeMessage {\n  /// Convert RealtimeMessage to ClientCollabMessage\n  /// If the message is not a collab message, it will return an empty vec\n  /// If the message is a collab message, it will return a vec with one element\n  /// If the message is a ClientCollabV1, it will return list of collab messages\n  pub fn split_messages_by_object_id(self) -> Result<MessageByObjectId, Error> {\n    match self {\n      RealtimeMessage::Collab(collab_message) => {\n        let object_id = collab_message.object_id().to_string();\n        let message = MessageByObjectId::new_with_message(\n          object_id,\n          vec![ClientCollabMessage::try_from(collab_message)?],\n        );\n        Ok(message)\n      },\n      RealtimeMessage::ClientCollabV1(_) => Err(anyhow!(\"ClientCollabV1 is not supported\")),\n      RealtimeMessage::ClientCollabV2(collab_messages) => Ok(collab_messages),\n      _ => Err(anyhow!(\n        \"Failed to convert RealtimeMessage:{} to ClientCollabMessage\",\n        self\n      )),\n    }\n  }\n\n  fn object_id(&self) -> Option<String> {\n    match self {\n      RealtimeMessage::Collab(msg) => Some(msg.object_id().to_string()),\n      RealtimeMessage::ClientCollabV1(msgs) => msgs.first().map(|msg| msg.object_id().to_string()),\n      RealtimeMessage::ClientCollabV2(msgs) => {\n        if let Some((object_id, _)) = msgs.iter().next() {\n          Some(object_id.to_string())\n        } else {\n          None\n        }\n      },\n      _ => None,\n    }\n  }\n\n  pub fn encode(&self) -> Result<Vec<u8>, Error> {\n    let data = DefaultOptions::new()\n      .with_fixint_encoding()\n      .allow_trailing_bytes()\n      .with_limit(max_sync_message_size() as u64)\n      .serialize(self)\n      .map_err(|e| {\n        anyhow!(\n          \"Failed to encode realtime message: {}, object_id:{:?}\",\n          e,\n          self.object_id()\n        )\n      })?;\n    Ok(data)\n  }\n\n  pub fn decode(data: &[u8]) -> Result<Self, Error> {\n    let message = DefaultOptions::new()\n      .with_fixint_encoding()\n      .allow_trailing_bytes()\n      .with_limit(max_sync_message_size() as u64)\n      .deserialize(data)?;\n    Ok(message)\n  }\n}\n\nimpl Display for RealtimeMessage {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      RealtimeMessage::Collab(msg) => f.write_fmt(format_args!(\"Collab:{}\", msg)),\n      RealtimeMessage::User(_) => f.write_fmt(format_args!(\"User\")),\n      RealtimeMessage::System(_) => f.write_fmt(format_args!(\"System\")),\n      RealtimeMessage::ClientCollabV1(_) => f.write_fmt(format_args!(\"ClientCollabV1\")),\n      RealtimeMessage::ClientCollabV2(_) => f.write_fmt(format_args!(\"ClientCollabV2\")),\n      RealtimeMessage::ServerCollabV1(_) => f.write_fmt(format_args!(\"ServerCollabV1\")),\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub enum SystemMessage {\n  RateLimit(u32),\n  KickOff,\n  DuplicateConnection,\n}\n\npub type MsgId = u64;\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub enum CollabMessage {\n  ClientInitSync(InitSync),\n  ClientUpdateSync(UpdateSync),\n  ClientAck(CollabAck),\n  ServerInitSync(ServerInit),\n  AwarenessSync(AwarenessSync),\n  ServerBroadcast(BroadcastSync),\n}\n\nimpl CollabMessage {\n  pub fn msg_id(&self) -> Option<MsgId> {\n    match self {\n      CollabMessage::ClientInitSync(value) => Some(value.msg_id),\n      CollabMessage::ClientUpdateSync(value) => Some(value.msg_id),\n      CollabMessage::ClientAck(value) => Some(value.msg_id),\n      CollabMessage::ServerInitSync(value) => Some(value.msg_id),\n      CollabMessage::ServerBroadcast(_) => None,\n      CollabMessage::AwarenessSync(_) => None,\n    }\n  }\n\n  pub fn len(&self) -> usize {\n    self.payload().len()\n  }\n  pub fn payload(&self) -> &Bytes {\n    match self {\n      CollabMessage::ClientInitSync(value) => &value.payload,\n      CollabMessage::ClientUpdateSync(value) => &value.payload,\n      CollabMessage::ClientAck(value) => &value.payload,\n      CollabMessage::ServerInitSync(value) => &value.payload,\n      CollabMessage::ServerBroadcast(value) => &value.payload,\n      CollabMessage::AwarenessSync(value) => &value.payload,\n    }\n  }\n  pub fn is_empty(&self) -> bool {\n    self.len() == 0\n  }\n  pub fn origin(&self) -> &CollabOrigin {\n    match self {\n      CollabMessage::ClientInitSync(value) => &value.origin,\n      CollabMessage::ClientUpdateSync(value) => &value.origin,\n      CollabMessage::ClientAck(value) => &value.origin,\n      CollabMessage::ServerInitSync(value) => &value.origin,\n      CollabMessage::ServerBroadcast(value) => &value.origin,\n      CollabMessage::AwarenessSync(value) => &value.origin,\n    }\n  }\n\n  pub fn uid(&self) -> Option<i64> {\n    self.origin().client_user_id()\n  }\n\n  pub fn object_id(&self) -> &str {\n    match self {\n      CollabMessage::ClientInitSync(value) => &value.object_id,\n      CollabMessage::ClientUpdateSync(value) => &value.object_id,\n      CollabMessage::ClientAck(value) => &value.object_id,\n      CollabMessage::ServerInitSync(value) => &value.object_id,\n      CollabMessage::ServerBroadcast(value) => &value.object_id,\n      CollabMessage::AwarenessSync(value) => &value.object_id,\n    }\n  }\n}\n\nimpl Display for CollabMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      CollabMessage::ClientInitSync(value) => Display::fmt(&value, f),\n      CollabMessage::ClientUpdateSync(value) => Display::fmt(&value, f),\n      CollabMessage::ClientAck(value) => Display::fmt(&value, f),\n      CollabMessage::ServerInitSync(value) => Display::fmt(&value, f),\n      CollabMessage::ServerBroadcast(value) => Display::fmt(&value, f),\n      CollabMessage::AwarenessSync(value) => Display::fmt(&value, f),\n    }\n  }\n}\n\nimpl From<CollabAck> for CollabMessage {\n  fn from(value: CollabAck) -> Self {\n    CollabMessage::ClientAck(value)\n  }\n}\n\nimpl From<BroadcastSync> for CollabMessage {\n  fn from(value: BroadcastSync) -> Self {\n    CollabMessage::ServerBroadcast(value)\n  }\n}\n\nimpl From<InitSync> for CollabMessage {\n  fn from(value: InitSync) -> Self {\n    CollabMessage::ClientInitSync(value)\n  }\n}\n\nimpl From<UpdateSync> for CollabMessage {\n  fn from(value: UpdateSync) -> Self {\n    CollabMessage::ClientUpdateSync(value)\n  }\n}\n\nimpl From<AwarenessSync> for CollabMessage {\n  fn from(value: AwarenessSync) -> Self {\n    CollabMessage::AwarenessSync(value)\n  }\n}\n\nimpl From<ServerInit> for CollabMessage {\n  fn from(value: ServerInit) -> Self {\n    CollabMessage::ServerInitSync(value)\n  }\n}\n\nimpl TryFrom<RealtimeMessage> for CollabMessage {\n  type Error = anyhow::Error;\n\n  fn try_from(value: RealtimeMessage) -> Result<Self, Self::Error> {\n    match value {\n      RealtimeMessage::Collab(msg) => Ok(msg),\n      _ => Err(anyhow!(\"Invalid message type.\")),\n    }\n  }\n}\n\nimpl From<CollabMessage> for RealtimeMessage {\n  fn from(msg: CollabMessage) -> Self {\n    Self::Collab(msg)\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/src/realtime_proto.rs",
    "content": "// This file is @generated by prost-build.\n#[allow(clippy::derive_partial_eq_without_eq)]\n#[derive(Clone, PartialEq, ::prost::Message)]\npub struct HttpRealtimeMessage {\n  #[prost(string, tag = \"1\")]\n  pub device_id: ::prost::alloc::string::String,\n  #[prost(bytes = \"vec\", tag = \"2\")]\n  pub payload: ::prost::alloc::vec::Vec<u8>,\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/src/server_message.rs",
    "content": "use crate::message::RealtimeMessage;\nuse crate::{CollabMessage, MsgId};\nuse anyhow::{anyhow, Error};\nuse bytes::Bytes;\nuse collab::core::origin::CollabOrigin;\nuse serde::{Deserialize, Serialize};\nuse serde_repr::Serialize_repr;\nuse std::fmt::{Display, Formatter};\n\n#[derive(Clone, Debug, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub enum ServerCollabMessage {\n  ClientAck(CollabAck),\n  ServerInitSync(ServerInit),\n  AwarenessSync(AwarenessSync),\n  ServerBroadcast(BroadcastSync),\n}\n\nimpl ServerCollabMessage {\n  pub fn object_id(&self) -> &str {\n    match self {\n      ServerCollabMessage::ClientAck(value) => &value.object_id,\n      ServerCollabMessage::ServerInitSync(value) => &value.object_id,\n      ServerCollabMessage::AwarenessSync(value) => &value.object_id,\n      ServerCollabMessage::ServerBroadcast(value) => &value.object_id,\n    }\n  }\n\n  pub fn msg_id(&self) -> Option<MsgId> {\n    match self {\n      ServerCollabMessage::ClientAck(value) => Some(value.msg_id),\n      ServerCollabMessage::ServerInitSync(value) => Some(value.msg_id),\n      ServerCollabMessage::AwarenessSync(_) => None,\n      ServerCollabMessage::ServerBroadcast(_) => None,\n    }\n  }\n\n  pub fn payload(&self) -> &Bytes {\n    match self {\n      ServerCollabMessage::ClientAck(value) => &value.payload,\n      ServerCollabMessage::ServerInitSync(value) => &value.payload,\n      ServerCollabMessage::AwarenessSync(value) => &value.payload,\n      ServerCollabMessage::ServerBroadcast(value) => &value.payload,\n    }\n  }\n\n  pub fn size(&self) -> usize {\n    match self {\n      ServerCollabMessage::ClientAck(msg) => msg.payload.len(),\n      ServerCollabMessage::ServerInitSync(msg) => msg.payload.len(),\n      ServerCollabMessage::AwarenessSync(msg) => msg.payload.len(),\n      ServerCollabMessage::ServerBroadcast(msg) => msg.payload.len(),\n    }\n  }\n\n  pub fn origin(&self) -> &CollabOrigin {\n    match self {\n      ServerCollabMessage::ClientAck(value) => &value.origin,\n      ServerCollabMessage::ServerInitSync(value) => &value.origin,\n      ServerCollabMessage::AwarenessSync(value) => &value.origin,\n      ServerCollabMessage::ServerBroadcast(value) => &value.origin,\n    }\n  }\n}\n\nimpl Display for ServerCollabMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ServerCollabMessage::ClientAck(value) => Display::fmt(&value, f),\n      ServerCollabMessage::ServerInitSync(value) => Display::fmt(&value, f),\n      ServerCollabMessage::AwarenessSync(value) => Display::fmt(&value, f),\n      ServerCollabMessage::ServerBroadcast(value) => Display::fmt(&value, f),\n    }\n  }\n}\n\nimpl TryFrom<CollabMessage> for ServerCollabMessage {\n  type Error = Error;\n\n  fn try_from(value: CollabMessage) -> Result<Self, Self::Error> {\n    match value {\n      CollabMessage::ClientAck(msg) => Ok(ServerCollabMessage::ClientAck(msg)),\n      CollabMessage::ServerInitSync(msg) => Ok(ServerCollabMessage::ServerInitSync(msg)),\n      CollabMessage::AwarenessSync(msg) => Ok(ServerCollabMessage::AwarenessSync(msg)),\n      CollabMessage::ServerBroadcast(msg) => Ok(ServerCollabMessage::ServerBroadcast(msg)),\n      _ => Err(anyhow!(\"Invalid collab message type.\")),\n    }\n  }\n}\n\nimpl From<ServerCollabMessage> for RealtimeMessage {\n  fn from(msg: ServerCollabMessage) -> Self {\n    Self::ServerCollabV1(vec![msg])\n  }\n}\n\nimpl From<ServerInit> for ServerCollabMessage {\n  fn from(value: ServerInit) -> Self {\n    ServerCollabMessage::ServerInitSync(value)\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct ServerInit {\n  pub origin: CollabOrigin,\n  pub object_id: String,\n  pub msg_id: MsgId,\n  /// \"The payload is encoded using the `EncoderV1` with the `Message` struct.\n  /// To decode the message, use the `MessageReader`.\"\n  /// ```text\n  ///   let mut decoder = DecoderV1::new(Cursor::new(payload));\n  ///   let reader = MessageReader::new(&mut decoder);\n  ///   for message in reader {\n  ///    ...\n  ///   }\n  /// ```\n  pub payload: Bytes,\n}\n\nimpl ServerInit {\n  pub fn new(origin: CollabOrigin, object_id: String, payload: Vec<u8>, msg_id: MsgId) -> Self {\n    Self {\n      origin,\n      object_id,\n      payload: Bytes::from(payload),\n      msg_id,\n    }\n  }\n}\n\nimpl Display for ServerInit {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"server init: [uid:{}|oid:{}|msg_id:{:?}|len:{}]\",\n      self.origin.client_user_id().unwrap_or(0),\n      self.object_id,\n      self.msg_id,\n      self.payload.len(),\n    ))\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct CollabAck {\n  pub origin: CollabOrigin,\n  pub object_id: String,\n  #[deprecated(note = \"since 0.2.18\")]\n  pub meta: AckMeta,\n  pub payload: Bytes,\n  pub code: u8,\n  pub msg_id: MsgId,\n  seq_num: u32,\n}\n\nimpl CollabAck {\n  #[allow(deprecated)]\n  pub fn new(origin: CollabOrigin, object_id: String, msg_id: MsgId, seq_num: u32) -> Self {\n    Self {\n      origin,\n      object_id,\n      meta: AckMeta::new(&msg_id),\n      payload: Bytes::from(vec![]),\n      code: AckCode::Success as u8,\n      msg_id,\n      seq_num,\n    }\n  }\n\n  pub fn with_payload<T: Into<Bytes>>(mut self, payload: T) -> Self {\n    self.payload = payload.into();\n    self\n  }\n\n  pub fn with_code(mut self, code: AckCode) -> Self {\n    self.code = code as u8;\n    self\n  }\n\n  pub fn get_code(&self) -> AckCode {\n    AckCode::from(self.code)\n  }\n\n  pub fn get_seq_num(&self) -> Option<u32> {\n    if self.get_code() == AckCode::Success {\n      Some(self.seq_num)\n    } else {\n      None\n    }\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug, Serialize_repr, Hash)]\n#[repr(u8)]\npub enum AckCode {\n  Success = 0,\n  CannotApplyUpdate = 1,\n  Retry = 2,\n  Internal = 3,\n  EncodeStateAsUpdateFail = 4,\n  MissUpdate = 5,\n}\n\nimpl From<u8> for AckCode {\n  fn from(value: u8) -> Self {\n    match value {\n      0 => AckCode::Success,\n      1 => AckCode::CannotApplyUpdate,\n      2 => AckCode::Retry,\n      3 => AckCode::Internal,\n      4 => AckCode::EncodeStateAsUpdateFail,\n      5 => AckCode::MissUpdate,\n      _ => AckCode::Internal,\n    }\n  }\n}\n\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct AckMeta {\n  pub data: String,\n  pub msg_id: MsgId,\n}\n\nimpl AckMeta {\n  fn new(msg_id: &MsgId) -> Self {\n    Self {\n      data: \"\".to_string(),\n      msg_id: *msg_id,\n    }\n  }\n}\n\nimpl Display for CollabAck {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"ack: [uid:{}|oid:{}|msg_id:{:?}|len:{}|code:{}|seq_nr:{}]\",\n      self.origin.client_user_id().unwrap_or(0),\n      self.object_id,\n      self.msg_id,\n      self.payload.len(),\n      self.code,\n      self.seq_num\n    ))\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct BroadcastSync {\n  pub origin: CollabOrigin,\n  pub(crate) object_id: String,\n  /// \"The payload is encoded using the `EncoderV1` with the `Message` struct.\n  /// It can be parsed into: Message::Sync::(SyncMessage::Update(update))\n  pub(crate) payload: Bytes,\n  pub seq_num: u32,\n}\n\nimpl BroadcastSync {\n  pub fn new(origin: CollabOrigin, object_id: String, payload: Vec<u8>, seq_num: u32) -> Self {\n    Self {\n      origin,\n      object_id,\n      payload: Bytes::from(payload),\n      seq_num,\n    }\n  }\n}\n\nimpl Display for BroadcastSync {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"broadcast: [oid:{}|len:{}|seq_num:{}]\",\n      self.object_id,\n      self.payload.len(),\n      self.seq_num\n    ))\n  }\n}\n\n///  ⚠️ ⚠️ ⚠️Compatibility Warning:\n///\n/// The structure of this struct is integral to maintaining compatibility with existing messages.\n/// Therefore, adding or removing any properties (fields) from this struct could disrupt the\n/// compatibility. Such changes may lead to issues in processing existing messages that expect\n/// the struct to have a specific format. It's crucial to carefully consider the implications\n/// of modifying this struct's fields\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct AwarenessSync {\n  pub(crate) object_id: String,\n  pub(crate) payload: Bytes,\n  pub(crate) origin: CollabOrigin,\n}\n\nimpl AwarenessSync {\n  pub fn new(object_id: String, payload: Vec<u8>, origin: CollabOrigin) -> Self {\n    Self {\n      object_id,\n      payload: Bytes::from(payload),\n      origin,\n    }\n  }\n}\n\nimpl Display for AwarenessSync {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"awareness: [|oid:{}|len:{}]\",\n      self.object_id,\n      self.payload.len(),\n    ))\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/src/user.rs",
    "content": "use database_entity::dto::AFWorkspaceMember;\nuse serde::{Deserialize, Serialize};\nuse std::fmt::{Debug, Display, Formatter};\nuse std::hash::Hash;\n\n#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub enum UserMessage {\n  ProfileChange(AFUserChange),\n  WorkspaceMemberChange(AFWorkspaceMemberChange),\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub struct AFUserChange {\n  pub uid: i64,\n  pub name: Option<String>,\n  pub email: Option<String>,\n  pub metadata: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub struct AFWorkspaceMemberChange {\n  added: Vec<AFWorkspaceMember>,\n  updated: Vec<AFWorkspaceMember>,\n  removed: Vec<AFWorkspaceMember>,\n}\n\n#[derive(Clone, Hash, PartialEq, Eq, Debug)]\npub struct UserDevice {\n  device_id: String,\n  uid: i64,\n}\n\nimpl UserDevice {\n  pub fn new(device_id: &str, uid: i64) -> Self {\n    Self {\n      device_id: device_id.to_string(),\n      uid,\n    }\n  }\n}\n\nimpl From<&RealtimeUser> for UserDevice {\n  fn from(user: &RealtimeUser) -> Self {\n    Self {\n      device_id: user.device_id.to_string(),\n      uid: user.uid,\n    }\n  }\n}\n\n/// A `RealtimeUser` represents an individual user's connection within a realtime collaboration environment.\n///\n/// Each instance uniquely identifies a user's connection through a combination of user ID, device ID, and session ID.\n/// This struct is crucial for managing user states, such as their active connections and interactions with the realtime server.\n///\n#[derive(Debug, Clone, Eq, PartialEq, Hash)]\npub struct RealtimeUser {\n  pub uid: i64,\n  /// `device_id`: A `String` representing the identifier of the device through which the user is connected.\n  pub device_id: String,\n  /// - `connect_at`: The time, in milliseconds since the Unix epoch, when the user established the connection to\n  ///   the realtime server. For users connecting multiple times from the same device, this represents the most\n  ///   recent connection time.\n  pub connect_at: i64,\n  /// - `session_id`: A `String` that uniquely identifies the current websocket connection session. This ID is\n  ///   generated anew for each connection established, providing a mechanism to uniquely identify and manage\n  ///   individual connection sessions. The session ID is used when cleanly handling user disconnections.\n  pub session_id: String,\n  /// - `app_version`: A `String` representing the version of the application that the user is using.\n  pub app_version: String,\n}\n\nimpl RealtimeUser {\n  pub fn new(\n    uid: i64,\n    device_id: String,\n    session_id: String,\n    connect_at: i64,\n    app_version: String,\n  ) -> Self {\n    Self {\n      uid,\n      device_id,\n      connect_at,\n      session_id,\n      app_version,\n    }\n  }\n\n  pub fn user_device(&self) -> String {\n    format!(\"{}:{}\", self.uid, self.device_id)\n  }\n}\n\nimpl Display for RealtimeUser {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\n      \"uid:{}|device_id:{}|connected_at:{}\",\n      self.uid, self.device_id, self.connect_at,\n    ))\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-entity/tests/main.rs",
    "content": "mod serde_test;\n"
  },
  {
    "path": "libs/collab-rt-entity/tests/serde_test.rs",
    "content": "use bytes::Bytes;\nuse collab::core::origin::CollabOrigin;\nuse collab_entity::CollabType;\nuse collab_rt_entity::user::UserMessage;\nuse collab_rt_entity::{CollabMessage, InitSync, MsgId};\nuse collab_rt_entity::{RealtimeMessage, SystemMessage};\nuse serde::{Deserialize, Serialize};\nuse std::fs::File;\nuse std::io::{Read, Write};\n\n#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize, Hash)]\npub struct AckMetaV1 {\n  #[serde(rename = \"sync_verbose\")]\n  pub verbose: String,\n  pub msg_id: MsgId,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[cfg_attr(\n  feature = \"actix_message\",\n  derive(actix::Message),\n  rtype(result = \"()\")\n)]\npub enum RealtimeMessageV1 {\n  Collab(CollabMessage),\n  User(UserMessage),\n  System(SystemMessage),\n}\n\n#[test]\nfn decode_0149_realtime_message_test() {\n  let collab_init = read_message_from_file(\"migration/0149/client_init\").unwrap();\n  assert!(matches!(collab_init, RealtimeMessage::Collab(_)));\n  if let RealtimeMessage::Collab(CollabMessage::ClientInitSync(init)) = collab_init {\n    assert_eq!(init.object_id, \"object id 1\");\n    assert_eq!(init.collab_type, CollabType::Document);\n    assert_eq!(init.workspace_id, \"workspace id 1\");\n    assert_eq!(init.msg_id, 1);\n    assert_eq!(init.payload, vec![1, 2, 3, 4]);\n  } else {\n    panic!(\"Failed to decode RealtimeMessage from file\");\n  }\n\n  let collab_update = read_message_from_file(\"migration/0149/collab_update\").unwrap();\n  assert!(matches!(collab_update, RealtimeMessage::Collab(_)));\n  if let RealtimeMessage::Collab(CollabMessage::ClientUpdateSync(update)) = collab_update {\n    assert_eq!(update.object_id, \"object id 1\");\n    assert_eq!(update.msg_id, 10);\n    assert_eq!(update.payload, Bytes::from(vec![5, 6, 7, 8]));\n  } else {\n    panic!(\"Failed to decode RealtimeMessage from file\");\n  }\n}\n\n#[test]\nfn decode_0147_realtime_message_test() {\n  let collab_init = read_message_from_file(\"migration/0147/client_init\").unwrap();\n  assert!(matches!(collab_init, RealtimeMessage::Collab(_)));\n  if let RealtimeMessage::Collab(CollabMessage::ClientInitSync(init)) = collab_init {\n    assert_eq!(init.object_id, \"object id 1\");\n    assert_eq!(init.collab_type, CollabType::Document);\n    assert_eq!(init.workspace_id, \"workspace id 1\");\n    assert_eq!(init.msg_id, 1);\n    assert_eq!(init.payload, vec![1, 2, 3, 4]);\n  } else {\n    panic!(\"Failed to decode RealtimeMessage from file\");\n  }\n\n  let collab_update = read_message_from_file(\"migration/0147/collab_update\").unwrap();\n  assert!(matches!(collab_update, RealtimeMessage::Collab(_)));\n  if let RealtimeMessage::Collab(CollabMessage::ClientUpdateSync(update)) = collab_update {\n    assert_eq!(update.object_id, \"object id 1\");\n    assert_eq!(update.msg_id, 10);\n    assert_eq!(update.payload, Bytes::from(vec![5, 6, 7, 8]));\n  } else {\n    panic!(\"Failed to decode RealtimeMessage from file\");\n  }\n}\n\n#[test]\nfn decode_version_2_collab_message_with_version_1_test_1() {\n  let version_2 = RealtimeMessage::Collab(CollabMessage::ClientInitSync(InitSync::new(\n    CollabOrigin::Empty,\n    \"1\".to_string(),\n    CollabType::Document,\n    \"w1\".to_string(),\n    1,\n    vec![0u8, 3],\n  )));\n\n  let version_2_bytes = version_2.encode().unwrap();\n  let version_1: RealtimeMessageV1 = bincode::deserialize(&version_2_bytes).unwrap();\n  match (version_1, version_2) {\n    (\n      RealtimeMessageV1::Collab(CollabMessage::ClientInitSync(init_1)),\n      RealtimeMessage::Collab(CollabMessage::ClientInitSync(init_2)),\n    ) => {\n      assert_eq!(init_1, init_2);\n    },\n    _ => panic!(\"Failed to convert RealtimeMessage2 to RealtimeMessage\"),\n  }\n}\n\n#[allow(dead_code)]\nfn write_message_to_file(\n  message: &RealtimeMessage,\n  file_path: &str,\n) -> Result<(), Box<dyn std::error::Error>> {\n  let data = message.encode().unwrap();\n  let mut file = File::create(file_path)?;\n  file.write_all(&data)?;\n  Ok(())\n}\n\n#[allow(dead_code)]\nfn read_message_from_file(file_path: &str) -> Result<RealtimeMessage, anyhow::Error> {\n  let mut file = File::open(file_path)?;\n  let mut buffer = Vec::new();\n  file.read_to_end(&mut buffer)?;\n  let message = RealtimeMessage::decode(&buffer)?;\n  Ok(message)\n}\n"
  },
  {
    "path": "libs/collab-rt-protocol/Cargo.toml",
    "content": "[package]\nname = \"collab-rt-protocol\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nyrs.workspace = true\nthiserror = \"1.0.56\"\nserde.workspace = true\ncollab = { workspace = true }\nbincode.workspace = true\nanyhow.workspace = true\ntracing.workspace = true\nasync-trait.workspace = true\ntokio = \"1.36.0\"\ncollab-entity.workspace = true\nuuid.workspace = true\n\n[features]\nverbose_log = []"
  },
  {
    "path": "libs/collab-rt-protocol/src/data_validation.rs",
    "content": "use anyhow::Error;\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse tracing::instrument;\nuse uuid::Uuid;\n\n#[inline]\npub async fn collab_from_encode_collab(object_id: &Uuid, data: &[u8]) -> Result<Collab, Error> {\n  let object_id = object_id.to_string();\n  let data = data.to_vec();\n\n  tokio::task::spawn_blocking(move || {\n    let encoded_collab = EncodedCollab::decode_from_bytes(&data)?;\n    let options = CollabOptions::new(object_id.to_string(), default_client_id())\n      .with_data_source(DataSource::DocStateV1(encoded_collab.doc_state.to_vec()));\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n\n    Ok::<_, Error>(collab)\n  })\n  .await?\n}\n\n#[instrument(level = \"trace\", skip(data), fields(len = %data.len()))]\n#[inline]\npub async fn validate_encode_collab(\n  object_id: &Uuid,\n  data: &[u8],\n  collab_type: &CollabType,\n) -> Result<(), Error> {\n  let collab = collab_from_encode_collab(object_id, data).await?;\n  collab_type.validate_require_data(&collab)?;\n  Ok::<(), Error>(())\n}\n"
  },
  {
    "path": "libs/collab-rt-protocol/src/lib.rs",
    "content": "mod data_validation;\nmod message;\nmod protocol;\n\npub use data_validation::*;\npub use message::*;\npub use protocol::*;\n"
  },
  {
    "path": "libs/collab-rt-protocol/src/message.rs",
    "content": "use std::fmt::{Debug, Display, Formatter};\n\nuse serde::{Deserialize, Serialize};\nuse thiserror::Error;\nuse yrs::updates::decoder::{Decode, Decoder};\nuse yrs::updates::encoder::{Encode, Encoder};\nuse yrs::StateVector;\n\n/// Tag id for [Message::Sync].\npub const MSG_SYNC: u8 = 0;\n/// Tag id for [Message::Awareness].\npub const MSG_AWARENESS: u8 = 1;\n/// Tag id for [Message::Auth].\npub const MSG_AUTH: u8 = 2;\npub const MSG_CUSTOM: u8 = 3;\n\npub const PERMISSION_DENIED: u8 = 0;\npub const PERMISSION_GRANTED: u8 = 1;\n\n#[derive(Debug, Eq, PartialEq)]\npub enum Message {\n  Sync(SyncMessage),\n  Auth(Option<String>),\n  Awareness(Vec<u8>),\n  Custom(CustomMessage),\n}\n\nimpl Encode for Message {\n  fn encode<E: Encoder>(&self, encoder: &mut E) {\n    match self {\n      Message::Sync(msg) => {\n        encoder.write_var(MSG_SYNC);\n        msg.encode(encoder);\n      },\n      Message::Auth(reason) => {\n        encoder.write_var(MSG_AUTH);\n        if let Some(reason) = reason {\n          encoder.write_var(PERMISSION_DENIED);\n          encoder.write_string(reason);\n        } else {\n          encoder.write_var(PERMISSION_GRANTED);\n        }\n      },\n      Message::Awareness(update) => {\n        encoder.write_var(MSG_AWARENESS);\n        encoder.write_buf(update)\n      },\n      Message::Custom(msg) => {\n        encoder.write_var(MSG_CUSTOM);\n        msg.encode(encoder)\n      },\n    }\n  }\n}\n\nimpl Decode for Message {\n  fn decode<D: Decoder>(decoder: &mut D) -> Result<Self, yrs::encoding::read::Error> {\n    let tag: u8 = decoder.read_var()?;\n    match tag {\n      MSG_SYNC => {\n        let msg = SyncMessage::decode(decoder)?;\n        Ok(Message::Sync(msg))\n      },\n      MSG_AWARENESS => {\n        let data = decoder.read_buf()?;\n        Ok(Message::Awareness(data.into()))\n      },\n      MSG_AUTH => {\n        let reason = if decoder.read_var::<u8>()? == PERMISSION_DENIED {\n          Some(decoder.read_string()?.to_string())\n        } else {\n          None\n        };\n        Ok(Message::Auth(reason))\n      },\n      MSG_CUSTOM => {\n        let msg = CustomMessage::decode(decoder)?;\n        Ok(Message::Custom(msg))\n      },\n      _ => Err(yrs::encoding::read::Error::UnexpectedValue),\n    }\n  }\n}\n\nimpl Display for Message {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      Message::Sync(sync_msg) => f.write_str(&sync_msg.to_string()),\n      Message::Auth(_) => f.write_str(\"Auth\"),\n      Message::Awareness(_) => f.write_str(\"Awareness\"),\n      Message::Custom(msg) => f.write_str(&msg.to_string()),\n    }\n  }\n}\n\n/// Tag id for [CustomMessage::MSG_CUSTOM_START_SYNC].\npub const MSG_CUSTOM_START_SYNC: u8 = 0;\n\n#[derive(Debug, Eq, PartialEq)]\npub enum CustomMessage {\n  SyncCheck(SyncMeta),\n}\n\nimpl Display for CustomMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      CustomMessage::SyncCheck(_) => f.write_str(\"SyncCheck\"),\n    }\n  }\n}\nimpl Encode for CustomMessage {\n  fn encode<E: Encoder>(&self, encoder: &mut E) {\n    match self {\n      CustomMessage::SyncCheck(msg) => {\n        encoder.write_var(MSG_CUSTOM_START_SYNC);\n        encoder.write_buf(msg.to_vec());\n      },\n    }\n  }\n}\n\nimpl Decode for CustomMessage {\n  fn decode<D: Decoder>(decoder: &mut D) -> Result<Self, yrs::encoding::read::Error> {\n    let tag: u8 = decoder.read_var()?;\n    match tag {\n      MSG_CUSTOM_START_SYNC => {\n        let buf = decoder.read_buf()?;\n        let meta = SyncMeta::from_vec(buf)?;\n        Ok(CustomMessage::SyncCheck(meta))\n      },\n      _ => Err(yrs::encoding::read::Error::UnexpectedValue),\n    }\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]\npub struct SyncMeta {\n  pub(crate) last_sync_at: i64,\n}\n\nimpl SyncMeta {\n  pub fn to_vec(&self) -> Vec<u8> {\n    bincode::serialize(self).unwrap()\n  }\n\n  pub fn from_vec(data: &[u8]) -> Result<Self, yrs::encoding::read::Error> {\n    let meta =\n      bincode::deserialize(data).map_err(|_| yrs::encoding::read::Error::UnexpectedValue)?;\n    Ok(meta)\n  }\n}\n\n/// Tag id for [SyncMessage::SyncStep1].\npub const MSG_SYNC_STEP_1: u8 = 0;\n/// Tag id for [SyncMessage::SyncStep2].\npub const MSG_SYNC_STEP_2: u8 = 1;\n/// Tag id for [SyncMessage::Update].\npub const MSG_SYNC_UPDATE: u8 = 2;\n\n#[derive(Debug, PartialEq, Eq)]\npub enum SyncMessage {\n  /// Sync step 1 message contains the [StateVector] from the remote side\n  SyncStep1(StateVector),\n  /// Sync step 2 message contains the encoded [yrs::Update] from the remote side\n  SyncStep2(Vec<u8>),\n  /// Update message contains the encoded [yrs::Update] from the remote side\n  Update(Vec<u8>),\n}\n\nimpl Display for SyncMessage {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      SyncMessage::SyncStep1(sv) => {\n        write!(f, \"SyncStep1({:?})\", sv)\n      },\n      SyncMessage::SyncStep2(data) => {\n        write!(f, \"SyncStep2({})\", data.len())\n      },\n      SyncMessage::Update(data) => {\n        write!(f, \"Update({})\", data.len())\n      },\n    }\n  }\n}\n\nimpl Encode for SyncMessage {\n  fn encode<E: Encoder>(&self, encoder: &mut E) {\n    match self {\n      SyncMessage::SyncStep1(sv) => {\n        encoder.write_var(MSG_SYNC_STEP_1);\n        encoder.write_buf(sv.encode_v1());\n      },\n      SyncMessage::SyncStep2(u) => {\n        encoder.write_var(MSG_SYNC_STEP_2);\n        encoder.write_buf(u);\n      },\n      SyncMessage::Update(u) => {\n        encoder.write_var(MSG_SYNC_UPDATE);\n        encoder.write_buf(u);\n      },\n    }\n  }\n}\n\nimpl Decode for SyncMessage {\n  fn decode<D: Decoder>(decoder: &mut D) -> Result<Self, yrs::encoding::read::Error> {\n    let tag: u8 = decoder.read_var()?;\n    match tag {\n      MSG_SYNC_STEP_1 => {\n        let buf = decoder.read_buf()?;\n        let sv = StateVector::decode_v1(buf)?;\n        Ok(SyncMessage::SyncStep1(sv))\n      },\n      MSG_SYNC_STEP_2 => {\n        let buf = decoder.read_buf()?;\n        Ok(SyncMessage::SyncStep2(buf.into()))\n      },\n      MSG_SYNC_UPDATE => {\n        let buf = decoder.read_buf()?;\n        Ok(SyncMessage::Update(buf.into()))\n      },\n      _ => Err(yrs::encoding::read::Error::UnexpectedValue),\n    }\n  }\n}\n\n#[derive(Debug, Error)]\npub enum RTProtocolError {\n  /// Incoming Y-protocol message couldn't be deserialized.\n  #[error(\"failed to deserialize message: {0}\")]\n  DecodingError(#[from] yrs::encoding::read::Error),\n\n  /// Applying incoming Y-protocol awareness update has failed.\n  #[error(\"failed to process awareness update: {0}\")]\n  YAwareness(#[from] collab::core::awareness::Error),\n\n  /// An incoming Y-protocol authorization request has been denied.\n  #[error(\"permission denied to access: {reason}\")]\n  PermissionDenied { reason: String },\n\n  /// Thrown whenever an unknown message tag has been sent.\n  #[error(\"unsupported message tag identifier: {0}\")]\n  Unsupported(u8),\n\n  #[error(\"{0}\")]\n  YrsTransaction(String),\n\n  #[error(\"{0}\")]\n  YrsApplyUpdate(String),\n\n  #[error(\"{0}\")]\n  YrsEncodeState(String),\n\n  #[error(transparent)]\n  BinCodeSerde(#[from] bincode::Error),\n\n  #[error(\"Missing Updates\")]\n  MissUpdates {\n    /// - `state_vector_v1`: Contains the last known state vector from the Collab. If `None`,\n    ///   this indicates that the receiver needs to perform a full initialization synchronization starting from sync step 0.\n    ///\n    /// The receiver uses this information to determine how to recover from the error,\n    /// either by recalculating the missing updates based on the `state_vector_v1` if it's available,\n    /// or by starting a full initialization sync if it's not.\n    state_vector_v1: Option<Vec<u8>>,\n    /// - `reason`: A human-readable explanation of why the error was raised, providing context for the missing updates.\n    reason: String,\n  },\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n}\nimpl From<std::io::Error> for RTProtocolError {\n  fn from(value: std::io::Error) -> Self {\n    RTProtocolError::Internal(value.into())\n  }\n}\n\n/// [MessageReader] can be used over the decoder to read these messages one by one in iterable\n/// fashion.\npub struct MessageReader<'a, D: Decoder>(&'a mut D);\n\nimpl<'a, D: Decoder> MessageReader<'a, D> {\n  pub fn new(decoder: &'a mut D) -> Self {\n    MessageReader(decoder)\n  }\n}\n\nimpl<D: Decoder> Iterator for MessageReader<'_, D> {\n  type Item = Result<Message, yrs::encoding::read::Error>;\n\n  fn next(&mut self) -> Option<Self::Item> {\n    match Message::decode(self.0) {\n      Ok(msg) => Some(Ok(msg)),\n      Err(yrs::encoding::read::Error::EndOfBuffer(_)) => None,\n      Err(error) => Some(Err(error)),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/collab-rt-protocol/src/protocol.rs",
    "content": "use std::borrow::BorrowMut;\nuse std::sync::{Arc, Weak};\n\nuse async_trait::async_trait;\nuse collab::core::awareness::{Awareness, AwarenessUpdate};\nuse collab::core::collab::TransactionMutExt;\nuse collab::core::origin::CollabOrigin;\nuse collab::lock::RwLock;\nuse collab::preclude::Collab;\nuse tokio::task::spawn_blocking;\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::{Encode, Encoder};\nuse yrs::{ReadTxn, StateVector, Transact, Update};\n\nuse crate::message::{CustomMessage, Message, RTProtocolError, SyncMessage, SyncMeta};\n\n/// A implementation of [CollabSyncProtocol].\n#[derive(Clone)]\npub struct ClientSyncProtocol;\n\n#[async_trait]\nimpl CollabSyncProtocol for ClientSyncProtocol {\n  fn check<E: Encoder>(&self, encoder: &mut E, last_sync_at: i64) -> Result<(), RTProtocolError> {\n    let meta = SyncMeta { last_sync_at };\n    Message::Custom(CustomMessage::SyncCheck(meta)).encode(encoder);\n    Ok(())\n  }\n\n  /// Handle reply for a sync-step-1 send from this replica previously. By default just apply\n  /// an update to current `awareness` document instance.\n  async fn handle_sync_step2(\n    &self,\n    origin: &CollabOrigin,\n    collab: &CollabRef,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    let update = decode_update(update).await?;\n    let mut lock = collab.write().await;\n    let collab = (*lock).borrow_mut();\n    let mut txn = collab\n      .get_awareness()\n      .doc()\n      .try_transact_mut_with(origin.clone())\n      .map_err(|err| {\n        RTProtocolError::YrsTransaction(format!(\"sync step2 transaction acquire: {}\", err))\n      })?;\n    txn.try_apply_update(update).map_err(|err| {\n      RTProtocolError::YrsApplyUpdate(format!(\"sync step2 apply update: {} \", err))\n    })?;\n\n    // If the client can't apply broadcast from server, which means the client is missing some\n    // updates.\n    match txn.store().pending_update() {\n      Some(update) => {\n        if cfg!(feature = \"verbose_log\") {\n          tracing::trace!(\n            \"Did find pending update, missing: {}\",\n            update.missing.is_empty()\n          );\n        }\n\n        // when client handle sync step 2 and found missing updates, just return MissUpdates Error.\n        // the state vector should be none that will trigger a client init sync\n        Err(RTProtocolError::MissUpdates {\n          state_vector_v1: None,\n          reason: \"client miss updates\".to_string(),\n        })\n      },\n      None => Ok(None),\n    }\n  }\n}\n\npub type CollabRef = Arc<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>;\n\npub type WeakCollabRef = Weak<RwLock<dyn BorrowMut<Collab> + Send + Sync + 'static>>;\n\n#[async_trait]\npub trait CollabSyncProtocol {\n  /// Handles incoming messages from the client/server\n  async fn handle_message(\n    &self,\n    message_origin: &CollabOrigin,\n    collab: &CollabRef,\n    msg: Message,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    match msg {\n      Message::Sync(msg) => match msg {\n        SyncMessage::SyncStep1(sv) => self.handle_sync_step1(collab, sv).await,\n        SyncMessage::SyncStep2(update) => {\n          self.handle_sync_step2(message_origin, collab, update).await\n        },\n        SyncMessage::Update(update) => self.handle_update(message_origin, collab, update).await,\n      },\n      Message::Auth(reason) => self.handle_auth(collab, reason).await,\n      //FIXME: where is the QueryAwareness protocol?\n      Message::Awareness(update) => {\n        let update = AwarenessUpdate::decode_v1(&update)?;\n        self\n          .handle_awareness_update(message_origin, collab, update)\n          .await\n      },\n      Message::Custom(msg) => self.handle_custom_message(collab, msg).await,\n    }\n  }\n\n  fn check<E: Encoder>(&self, _encoder: &mut E, _last_sync_at: i64) -> Result<(), RTProtocolError> {\n    Ok(())\n  }\n\n  fn start<E: Encoder>(\n    &self,\n    awareness: &Awareness,\n    encoder: &mut E,\n  ) -> Result<(), RTProtocolError> {\n    let (state_vector, awareness_update) = {\n      let state_vector = awareness\n        .doc()\n        .try_transact()\n        .map_err(|e| RTProtocolError::YrsTransaction(e.to_string()))?\n        .state_vector();\n      let awareness_update = awareness.update()?;\n      (state_vector, awareness_update.encode_v1())\n    };\n\n    // 1. encode doc state vector\n    Message::Sync(SyncMessage::SyncStep1(state_vector)).encode(encoder);\n\n    // // 2. if the sync_before is false, which means the doc is not synced before, then we need to\n    // // send the full update to the server.\n    // if !sync_before {\n    //   if let Ok(txn) = awareness.doc().try_transact() {\n    //     let update = txn.encode_state_as_update_v1(&StateVector::default());\n    //     Message::Sync(SyncMessage::SyncStep2(update)).encode(encoder);\n    //   }\n    // }\n\n    // 3. encode awareness update\n    Message::Awareness(awareness_update).encode(encoder);\n    Ok(())\n  }\n\n  /// Given a [StateVector] of a remote side, calculate missing\n  /// updates. Returns a sync-step-2 message containing a calculated update.\n  async fn handle_sync_step1(\n    &self,\n    collab: &CollabRef,\n    sv: StateVector,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    // calculate missing updates base on the input state vector\n    let update = {\n      let lock = collab.read().await;\n      let collab = lock.borrow();\n      let txn = collab.get_awareness().doc().try_transact().map_err(|err| {\n        RTProtocolError::YrsTransaction(format!(\"fail to handle sync step1. error: {}\", err))\n      })?;\n      txn.encode_diff_v1(&sv)\n    };\n    Ok(Some(\n      Message::Sync(SyncMessage::SyncStep2(update)).encode_v1(),\n    ))\n  }\n\n  /// Handle reply for a sync-step-1 send from this replica previously. By default just apply\n  /// an update to current `awareness` document instance.\n  async fn handle_sync_step2(\n    &self,\n    origin: &CollabOrigin,\n    collab: &CollabRef,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError>;\n\n  /// Handle continuous update send from the client. By default just apply an update to a current\n  /// `awareness` document instance.\n  async fn handle_update(\n    &self,\n    origin: &CollabOrigin,\n    collab: &CollabRef,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    self.handle_sync_step2(origin, collab, update).await\n  }\n\n  async fn handle_auth(\n    &self,\n    _collab: &CollabRef,\n    deny_reason: Option<String>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    if let Some(reason) = deny_reason {\n      Err(RTProtocolError::PermissionDenied { reason })\n    } else {\n      Ok(None)\n    }\n  }\n\n  /// Reply to awareness query or just incoming [AwarenessUpdate], where current `awareness`\n  /// instance is being updated with incoming data.\n  async fn handle_awareness_update(\n    &self,\n    _message_origin: &CollabOrigin,\n    collab: &CollabRef,\n    update: AwarenessUpdate,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    let mut lock = collab.write().await;\n    let collab = (*lock).borrow_mut();\n    collab.get_awareness().apply_update(update)?;\n    Ok(None)\n  }\n\n  async fn handle_custom_message(\n    &self,\n    _collab: &CollabRef,\n    _msg: CustomMessage,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    Ok(None)\n  }\n}\n\npub const LARGE_UPDATE_THRESHOLD: usize = 1024 * 1024; // 1MB\n\n#[inline]\npub async fn decode_update(update: Vec<u8>) -> Result<Update, yrs::encoding::read::Error> {\n  let update = if update.len() > LARGE_UPDATE_THRESHOLD {\n    spawn_blocking(move || Update::decode_v1(&update))\n      .await\n      .map_err(|err| yrs::encoding::read::Error::Custom(err.to_string()))?\n  } else {\n    Update::decode_v1(&update)\n  }?;\n  Ok(update)\n}\n"
  },
  {
    "path": "libs/collab-stream/Cargo.toml",
    "content": "[package]\nname = \"collab-stream\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nredis = { workspace = true, features = [\"aio\", \"tokio-comp\", \"connection-manager\", \"streams\", \"uuid\", \"bytes\"] }\ntokio = { version = \"1.26\", features = [\"rt-multi-thread\", \"macros\"] }\ntokio-stream = { version = \"0.1.14\" }\nthiserror = \"1.0.58\"\nanyhow.workspace = true\nuuid.workspace = true\nfutures = \"0.3.30\"\ntracing = \"0.1\"\nserde = { version = \"1\", features = [\"derive\"] }\nbincode = \"1.3.3\"\nbytes.workspace = true\ncollab.workspace = true\ncollab-entity.workspace = true\nserde_json.workspace = true\nchrono = \"0.4\"\ntokio-util = { version = \"0.7\" }\nprost.workspace = true\nasync-stream.workspace = true\nasync-trait.workspace = true\nprometheus-client.workspace = true\nzstd = \"0.13\"\nloole = \"0.4.0\"\ndashmap.workspace = true\nappflowy-proto.workspace = true\n\n[dev-dependencies]\nfutures = \"0.3.30\"\nrand = \"0.8.5\""
  },
  {
    "path": "libs/collab-stream/src/awareness_gossip.rs",
    "content": "use crate::error::StreamError;\nuse crate::model::AwarenessStreamUpdate;\nuse dashmap::mapref::entry::Entry;\nuse dashmap::DashMap;\nuse redis::aio::MultiplexedConnection;\nuse redis::{AsyncCommands, Client, RedisError, Value};\nuse std::sync::Arc;\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tokio::sync::Mutex;\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse tokio_stream::StreamExt;\nuse uuid::Uuid;\n\ntype AwarenessSender = UnboundedSender<Arc<AwarenessStreamUpdate>>;\ntype ScopedAwarenessSender = UnboundedSender<(Uuid, Arc<AwarenessStreamUpdate>)>;\n\npub struct AwarenessGossip {\n  conn: MultiplexedConnection,\n  collabs: Arc<DashMap<Uuid, AwarenessSender>>,\n  workspaces: Arc<DashMap<Uuid, ScopedAwarenessSender>>,\n}\n\nimpl AwarenessGossip {\n  pub async fn new(client: &Client) -> Result<Self, RedisError> {\n    let collabs: Arc<DashMap<Uuid, AwarenessSender>> = Arc::new(DashMap::new());\n    let workspaces: Arc<DashMap<Uuid, ScopedAwarenessSender>> = Arc::new(DashMap::new());\n    let mut pub_sub = client.get_async_pubsub().await?;\n    pub_sub.psubscribe(\"af:awareness:*\").await?;\n    let conn = client.get_multiplexed_async_connection().await?;\n\n    let weak_collabs = Arc::downgrade(&collabs);\n    let workspaces_clone = workspaces.clone();\n    let _receive_awareness_pubsub = tokio::spawn(async move {\n      let mut stream = pub_sub.into_on_message();\n      while let Some(message) = stream.next().await {\n        if let Some(collabs) = weak_collabs.upgrade() {\n          match Self::parse_update(message) {\n            Ok((workspace_id, object_id, awareness_update)) => {\n              Self::dispatch_collab_awareness_update(\n                &collabs,\n                &workspaces_clone,\n                workspace_id,\n                object_id,\n                awareness_update.into(),\n              );\n            },\n            Err(err) => tracing::error!(\"failed to parse awareness message: {}\", err),\n          }\n        } else {\n          return; // dropped collabs\n        }\n      }\n    });\n    Ok(Self {\n      conn,\n      collabs,\n      workspaces,\n    })\n  }\n\n  fn dispatch_collab_awareness_update(\n    collabs: &DashMap<Uuid, AwarenessSender>,\n    workspaces: &DashMap<Uuid, ScopedAwarenessSender>,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    awareness_update: Arc<AwarenessStreamUpdate>,\n  ) {\n    tracing::trace!(\n      \"dispatch awareness update for {}/{} from {}\",\n      workspace_id,\n      object_id,\n      awareness_update.sender\n    );\n    if let Entry::Occupied(e) = workspaces.entry(workspace_id) {\n      // dispatch awareness update to workspace group (sync v2)\n      let channel = e.get();\n      if channel.send((object_id, awareness_update.clone())).is_err() {\n        e.remove();\n      }\n    }\n\n    // dispatch awareness update to collab group (sync v1)\n    if let Entry::Occupied(e) = collabs.entry(object_id) {\n      let channel = e.get();\n      if channel.send(awareness_update).is_err() {\n        e.remove();\n      }\n    }\n  }\n\n  pub async fn send(\n    &self,\n    workspace_id: &str,\n    object_id: &str,\n    update: &AwarenessStreamUpdate,\n  ) -> Result<(), StreamError> {\n    let json = serde_json::to_string(update)?;\n    let publish_key = format!(\"af:awareness:{workspace_id}:{object_id}\");\n    let mut pubsub = self.conn.clone();\n    let _: Value = pubsub.publish(publish_key, json).await?;\n    Ok(())\n  }\n\n  pub async fn sink(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<AwarenessUpdateSink, StreamError> {\n    let sink = AwarenessUpdateSink::new(self.conn.clone(), workspace_id, object_id);\n    Ok(sink)\n  }\n\n  pub fn collab_awareness_stream(\n    &self,\n    object_id: &Uuid,\n  ) -> UnboundedReceiver<Arc<AwarenessStreamUpdate>> {\n    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n    self.collabs.insert(*object_id, tx);\n    rx\n  }\n\n  pub fn workspace_awareness_stream(\n    &self,\n    workspace_id: &Uuid,\n  ) -> UnboundedReceiverStream<(Uuid, Arc<AwarenessStreamUpdate>)> {\n    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();\n    self.workspaces.insert(*workspace_id, tx);\n    UnboundedReceiverStream::new(rx)\n  }\n\n  fn parse_update(msg: redis::Msg) -> Result<(Uuid, Uuid, AwarenessStreamUpdate), StreamError> {\n    let channel_name = msg.get_channel_name();\n    tracing::trace!(\"received awareness stream update for {}\", channel_name);\n    let (workspace_id, object_id) = Self::parse_channel_name(channel_name)\n      .ok_or_else(|| StreamError::InvalidStreamKey(channel_name.to_string()))?;\n    let payload = msg.get_payload_bytes();\n    let update = serde_json::from_slice::<AwarenessStreamUpdate>(payload)\n      .map_err(StreamError::SerdeJsonError)?;\n    Ok((workspace_id, object_id, update))\n  }\n\n  fn parse_channel_name(channel_name: &str) -> Option<(Uuid, Uuid)> {\n    let mut channel_segments = channel_name.split(':');\n    if channel_segments.next() != Some(\"af\") {\n      return None;\n    }\n    if channel_segments.next() != Some(\"awareness\") {\n      return None;\n    }\n    let workspace_id = channel_segments.next()?;\n    let workspace_id = Uuid::parse_str(workspace_id).ok()?;\n    let object_id = channel_segments.next()?;\n    let object_id = Uuid::parse_str(object_id).ok()?;\n    Some((workspace_id, object_id))\n  }\n}\n\npub struct AwarenessUpdateSink {\n  conn: Mutex<MultiplexedConnection>,\n  publish_key: String,\n}\n\nimpl AwarenessUpdateSink {\n  pub fn new(conn: MultiplexedConnection, workspace_id: &Uuid, object_id: &Uuid) -> Self {\n    let publish_key = format!(\"af:awareness:{workspace_id}:{object_id}\");\n    AwarenessUpdateSink {\n      conn: conn.into(),\n      publish_key,\n    }\n  }\n\n  pub async fn send(&self, msg: &AwarenessStreamUpdate) -> Result<(), StreamError> {\n    let mut conn = self.conn.lock().await;\n    Self::notify_awareness_change(&mut conn, &self.publish_key, msg).await?;\n    Ok(())\n  }\n\n  /// Send a Redis pub-sub message to notify other clients about the awareness change.\n  async fn notify_awareness_change(\n    conn: &mut MultiplexedConnection,\n    pubsub_key: &str,\n    update: &AwarenessStreamUpdate,\n  ) -> Result<(), StreamError> {\n    tracing::trace!(\"notify awareness change for {}: {:?}\", pubsub_key, update);\n    let json = serde_json::to_string(update)?;\n    let _: redis::Value = conn.publish(pubsub_key, json).await?;\n    Ok(())\n  }\n}\n\n#[cfg(test)]\nmod test {\n  use crate::awareness_gossip::AwarenessGossip;\n  use crate::model::AwarenessStreamUpdate;\n  use collab::core::awareness::AwarenessUpdate;\n  use collab::core::origin::CollabOrigin;\n  use uuid::Uuid;\n\n  #[tokio::test]\n  async fn subscribe_awareness_change_for_many_collabs() {\n    let client = redis::Client::open(\"redis://127.0.0.1:6379/\").unwrap();\n    let gossip = AwarenessGossip::new(&client).await.unwrap();\n    const COLLAB_COUNT: usize = 10_000;\n    let mut collabs = Vec::with_capacity(COLLAB_COUNT);\n    for _ in 0..COLLAB_COUNT {\n      let workspace_id = Uuid::new_v4();\n      let object_id = Uuid::new_v4();\n      let sink = gossip.sink(&workspace_id, &object_id).await.unwrap();\n      let stream = gossip.collab_awareness_stream(&object_id);\n      collabs.push((sink, stream));\n    }\n\n    for (sink, _) in collabs.iter() {\n      sink\n        .send(&AwarenessStreamUpdate {\n          data: AwarenessUpdate {\n            clients: Default::default(),\n          },\n          sender: CollabOrigin::Server,\n        })\n        .await\n        .unwrap();\n    }\n\n    for (_, stream) in collabs.iter_mut() {\n      stream.recv().await.unwrap();\n    }\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/client.rs",
    "content": "use crate::awareness_gossip::{AwarenessGossip, AwarenessUpdateSink};\nuse crate::collab_update_sink::CollabUpdateSink;\nuse crate::error::StreamError;\nuse crate::lease::{Lease, LeaseAcquisition};\nuse crate::metrics::CollabStreamMetrics;\nuse crate::model::{AwarenessStreamUpdate, CollabStreamUpdate, MessageId, UpdateStreamMessage};\nuse crate::stream_router::{FromRedisStream, StreamRouter, StreamRouterOptions};\nuse collab_entity::CollabType;\nuse futures::{Stream, StreamExt};\nuse redis::aio::ConnectionManager;\nuse redis::streams::StreamReadReply;\nuse redis::{AsyncCommands, FromRedisValue};\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::mpsc::UnboundedReceiver;\nuse tracing::trace;\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub struct CollabRedisStream {\n  connection_manager: ConnectionManager,\n  stream_router: Arc<StreamRouter>,\n  awareness_gossip: Arc<AwarenessGossip>,\n}\n\nimpl CollabRedisStream {\n  pub const LEASE_TTL: Duration = Duration::from_secs(60);\n\n  pub async fn new(\n    redis_client: redis::Client,\n    metrics: Arc<CollabStreamMetrics>,\n  ) -> Result<Self, redis::RedisError> {\n    let router_options = StreamRouterOptions {\n      worker_count: 60,\n      xread_streams: 100,\n      xread_block_millis: Some(5000),\n      xread_count: None,\n    };\n    let stream_router = Arc::new(StreamRouter::with_options(\n      &redis_client,\n      metrics,\n      router_options,\n    )?);\n    let awareness_gossip = Arc::new(AwarenessGossip::new(&redis_client).await?);\n    let connection_manager = redis_client.get_connection_manager().await?;\n    Ok(Self::new_with_connection_manager(\n      connection_manager,\n      stream_router,\n      awareness_gossip,\n    ))\n  }\n\n  pub fn new_with_connection_manager(\n    connection_manager: ConnectionManager,\n    stream_router: Arc<StreamRouter>,\n    awareness_gossip: Arc<AwarenessGossip>,\n  ) -> Self {\n    Self {\n      connection_manager,\n      stream_router,\n      awareness_gossip,\n    }\n  }\n\n  pub async fn lease(\n    &self,\n    workspace_id: &str,\n    object_id: &str,\n  ) -> Result<Option<LeaseAcquisition>, StreamError> {\n    let lease_key = format!(\"af:{}:{}:snapshot_lease\", workspace_id, object_id);\n    self\n      .connection_manager\n      .lease(lease_key, Self::LEASE_TTL)\n      .await\n  }\n\n  pub fn collab_update_sink(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    collab_type: CollabType,\n  ) -> CollabUpdateSink {\n    let stream_key = UpdateStreamMessage::stream_key(workspace_id);\n    CollabUpdateSink::new(\n      self.connection_manager.clone(),\n      stream_key,\n      *object_id,\n      collab_type,\n    )\n  }\n\n  pub async fn awareness_update_sink(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n  ) -> Result<AwarenessUpdateSink, StreamError> {\n    self.awareness_gossip.sink(workspace_id, object_id).await\n  }\n\n  /// Reads all collab updates for a given `workspace_id`:`object_id` entry, starting\n  /// from a given message id. Once Redis stream return no more results, the stream will be closed.\n  pub async fn current_collab_updates(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    since: Option<MessageId>,\n  ) -> Result<Vec<(MessageId, CollabStreamUpdate)>, StreamError> {\n    let stream_key = UpdateStreamMessage::stream_key(workspace_id);\n    let since = since.unwrap_or_default().to_string();\n    let mut conn = self.connection_manager.clone();\n    let mut result = Vec::new();\n    let mut reply: StreamReadReply = conn.xread(&[&stream_key], &[&since]).await?;\n    if let Some(key) = reply.keys.pop() {\n      if key.key == stream_key {\n        for stream_id in key.ids {\n          let msg_oid = stream_id\n            .map\n            .get(\"oid\")\n            .and_then(|v| Uuid::from_redis_value(v).ok())\n            .unwrap_or_default();\n          if &msg_oid != object_id {\n            continue; // this is not the object we are looking for\n          }\n          let message = UpdateStreamMessage::from_redis_stream(&stream_id.id, &stream_id.map)\n            .map_err(StreamError::Internal)?;\n          let message_id = MessageId::from(message.last_message_id);\n          let stream_update = CollabStreamUpdate::from(message);\n          tracing::trace!(\n            \"replaying current collab update `{}` for {}\",\n            message_id,\n            msg_oid\n          );\n          result.push((message_id, stream_update));\n        }\n      }\n    }\n    Ok(result)\n  }\n\n  /// Reads all collab updates for a given `workspace_id`:`object_id` entry, starting\n  /// from a given message id. This stream will be kept alive and pass over all future messages\n  /// coming from corresponding Redis stream until explicitly closed.\n  pub fn live_collab_updates(\n    &self,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    since: Option<MessageId>,\n  ) -> impl Stream<Item = Result<(MessageId, CollabStreamUpdate), StreamError>> {\n    let stream_key = UpdateStreamMessage::stream_key(workspace_id);\n    let since = since.map(|id| id.to_string());\n    let object_id = *object_id;\n    let mut reader = self\n      .stream_router\n      .observe::<UpdateStreamMessage>(stream_key, since);\n    async_stream::try_stream! {\n      while let Some(Ok(msg)) = reader.next().await {\n        if msg.object_id == object_id {\n          let message_id = MessageId::from(msg.last_message_id);\n          let collab_update = CollabStreamUpdate::from(msg);\n          tracing::trace!(\"incoming collab update `{}`\", message_id);\n          yield (message_id, collab_update);\n        }\n      }\n    }\n  }\n\n  pub fn awareness_updates(\n    &self,\n    object_id: &Uuid,\n  ) -> UnboundedReceiver<Arc<AwarenessStreamUpdate>> {\n    self.awareness_gossip.collab_awareness_stream(object_id)\n  }\n\n  pub async fn delete_stream_messages(\n    &self,\n    stream_key: &str,\n    message_ids: &[MessageId],\n  ) -> Result<(), StreamError> {\n    trace!(\n      \"deleting messages from stream `{}`: {}\",\n      stream_key,\n      message_ids\n        .iter()\n        .map(|id| id.to_string())\n        .collect::<Vec<String>>()\n        .join(\", \")\n    );\n    let mut conn = self.connection_manager.clone();\n    let _: redis::Value = conn.xdel(stream_key, message_ids).await?;\n    Ok(())\n  }\n\n  pub async fn prune_update_stream(\n    &self,\n    stream_key: &str,\n    mut message_id: MessageId,\n  ) -> Result<usize, StreamError> {\n    let mut conn = self.connection_manager.clone();\n    // we want to delete everything <= message_id\n    message_id.sequence_number += 1;\n    let value = conn\n      .send_packed_command(\n        redis::cmd(\"XTRIM\")\n          .arg(stream_key)\n          .arg(\"MINID\")\n          .arg(format!(\"{}\", message_id)),\n      )\n      .await?;\n    let count = usize::from_redis_value(&value)?;\n    drop(conn);\n    tracing::debug!(\n      \"pruned redis update stream `{}` <= `{}` ({} objects)\",\n      stream_key,\n      message_id,\n      count\n    );\n    Ok(count)\n  }\n\n  pub async fn prune_awareness_stream(&self, stream_key: &str) -> Result<(), StreamError> {\n    let mut conn = self.connection_manager.clone();\n    let value = conn\n      .send_packed_command(\n        redis::cmd(\"XTRIM\")\n          .arg(stream_key)\n          .arg(\"MAXLEN\")\n          .arg(\"=\")\n          .arg(0),\n      )\n      .await?;\n    let count = usize::from_redis_value(&value)?;\n    drop(conn);\n    tracing::debug!(\n      \"pruned redis awareness stream {} ({} objects)\",\n      stream_key,\n      count\n    );\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/collab_update_sink.rs",
    "content": "use crate::error::StreamError;\nuse crate::model::{CollabStreamUpdate, MessageId, UpdateStreamMessage};\nuse appflowy_proto::ObjectId;\nuse collab_entity::CollabType;\nuse redis::aio::ConnectionManager;\nuse tokio::sync::Mutex;\n\npub struct CollabUpdateSink {\n  conn: Mutex<ConnectionManager>,\n  stream_key: String,\n  object_id: ObjectId,\n  collab_type: CollabType,\n}\n\nimpl CollabUpdateSink {\n  pub fn new(\n    conn: ConnectionManager,\n    stream_key: String,\n    object_id: ObjectId,\n    collab_type: CollabType,\n  ) -> Self {\n    CollabUpdateSink {\n      conn: conn.into(),\n      stream_key,\n      object_id,\n      collab_type,\n    }\n  }\n\n  pub async fn send(&self, msg: &CollabStreamUpdate) -> Result<MessageId, StreamError> {\n    let mut lock = self.conn.lock().await;\n    let msg_id: String = UpdateStreamMessage::prepare_command(\n      &self.stream_key,\n      &self.object_id,\n      self.collab_type,\n      &msg.sender,\n      msg.data.clone(),\n      msg.flags,\n    )\n    .query_async(&mut *lock)\n    .await?;\n    MessageId::try_from(&*msg_id)\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/error.rs",
    "content": "use redis::RedisError;\n\n#[derive(thiserror::Error, Debug)]\npub enum StreamError {\n  #[error(transparent)]\n  RedisError(#[from] RedisError),\n\n  #[error(\"Stream already exist: {0}\")]\n  StreamAlreadyExist(String),\n\n  #[error(\"Stream not exist: {0}\")]\n  StreamNotExist(String),\n\n  #[error(\"Unexpected value: {0}\")]\n  UnexpectedValue(String),\n\n  #[error(transparent)]\n  Utf8Error(#[from] std::str::Utf8Error),\n\n  #[error(\"Invalid format\")]\n  InvalidFormat,\n\n  #[error(transparent)]\n  ParseIntError(#[from] std::num::ParseIntError),\n\n  #[error(\"Stream group already exists\")]\n  GroupAlreadyExists(String),\n\n  #[error(transparent)]\n  SerdeJsonError(#[from] serde_json::Error),\n\n  #[error(transparent)]\n  BinCodeSerde(#[from] bincode::Error),\n\n  #[error(\"failed to decode update: {0}\")]\n  UpdateError(#[from] collab::preclude::encoding::read::Error),\n\n  #[error(\"I/O error: {0}\")]\n  IO(#[from] std::io::Error),\n\n  #[error(\"Internal error: {0}\")]\n  Internal(anyhow::Error),\n\n  #[error(\"Couldn't parse stream key: {0}\")]\n  InvalidStreamKey(String),\n}\n\nimpl StreamError {\n  pub fn is_stream_not_exist(&self) -> bool {\n    matches!(self, StreamError::StreamNotExist(_))\n  }\n}\n\npub fn internal<T: ToString>(msg: T) -> RedisError {\n  let msg = msg.to_string();\n  RedisError::from((redis::ErrorKind::TypeError, \"\", msg))\n}\n"
  },
  {
    "path": "libs/collab-stream/src/lease.rs",
    "content": "use crate::error::StreamError;\nuse async_trait::async_trait;\nuse redis::aio::ConnectionManager;\nuse redis::Value;\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\n\nconst RELEASE_SCRIPT: &str = r#\"\nif redis.call(\"GET\", KEYS[1]) == ARGV[1] then\n  return redis.call(\"DEL\", KEYS[1])\nelse\n  return 0\nend\n\"#;\n\npub struct LeaseAcquisition {\n  conn: Option<ConnectionManager>,\n  stream_key: String,\n  token: u128,\n}\n\nimpl LeaseAcquisition {\n  pub async fn release(&mut self) -> Result<bool, StreamError> {\n    if let Some(conn) = self.conn.take() {\n      Self::release_internal(conn, &self.stream_key, self.token).await\n    } else {\n      Ok(false)\n    }\n  }\n\n  async fn release_internal<S: AsRef<str>>(\n    mut conn: ConnectionManager,\n    stream_key: S,\n    token: u128,\n  ) -> Result<bool, StreamError> {\n    let script = redis::Script::new(RELEASE_SCRIPT);\n    let result: i32 = script\n      .key(stream_key.as_ref())\n      .arg(token.to_le_bytes().as_slice())\n      .invoke_async(&mut conn)\n      .await?;\n    Ok(result == 1)\n  }\n}\n\nimpl Drop for LeaseAcquisition {\n  fn drop(&mut self) {\n    if let Some(conn) = self.conn.take() {\n      let stream_key = self.stream_key.clone();\n      let token = self.token;\n      tokio::spawn(async move {\n        if let Err(err) = Self::release_internal(conn, stream_key, token).await {\n          tracing::error!(\"error while releasing lease (drop): {}\", err);\n        }\n      });\n    }\n  }\n}\n\n/// This is Redlock algorithm implementation.\n/// See: https://redis.io/docs/latest/commands/set#patterns\n#[async_trait]\npub trait Lease {\n  /// Attempt to acquire lease on a stream for a given time-to-live.\n  /// Returns `None` if the lease could not be acquired.\n  async fn lease(\n    &self,\n    stream_key: String,\n    ttl: Duration,\n  ) -> Result<Option<LeaseAcquisition>, StreamError>;\n}\n\n#[async_trait]\nimpl Lease for ConnectionManager {\n  async fn lease(\n    &self,\n    stream_key: String,\n    ttl: Duration,\n  ) -> Result<Option<LeaseAcquisition>, StreamError> {\n    let mut conn = self.clone();\n    let ttl = ttl.as_millis() as u64;\n    let token = SystemTime::now()\n      .duration_since(UNIX_EPOCH)\n      .unwrap()\n      .as_millis();\n    tracing::trace!(\"acquiring lease `{}` for {}ms\", stream_key, ttl);\n    let result: Value = redis::cmd(\"SET\")\n      .arg(&stream_key)\n      .arg(token.to_le_bytes().as_slice())\n      .arg(\"NX\")\n      .arg(\"PX\")\n      .arg(ttl)\n      .query_async(&mut conn)\n      .await?;\n\n    match result {\n      Value::Okay => Ok(Some(LeaseAcquisition {\n        conn: Some(conn),\n        stream_key,\n        token,\n      })),\n      o => {\n        tracing::trace!(\"lease locked: {:?}\", o);\n        Ok(None)\n      },\n    }\n  }\n}\n\n#[cfg(test)]\nmod test {\n  use crate::lease::Lease;\n  use redis::Client;\n\n  #[tokio::test]\n  async fn lease_acquisition() {\n    let redis_client = Client::open(\"redis://localhost:6379\").unwrap();\n    let conn = redis_client.get_connection_manager().await.unwrap();\n\n    let l1 = conn\n      .lease(\"stream1\".into(), std::time::Duration::from_secs(1))\n      .await\n      .unwrap();\n\n    assert!(l1.is_some(), \"should successfully acquire lease\");\n\n    let l2 = conn\n      .lease(\"stream1\".into(), std::time::Duration::from_secs(1))\n      .await\n      .unwrap();\n\n    assert!(l2.is_none(), \"should fail to acquire lease\");\n\n    l1.unwrap().release().await.unwrap();\n\n    let l3 = conn\n      .lease(\"stream1\".into(), std::time::Duration::from_secs(1))\n      .await\n      .unwrap();\n\n    assert!(\n      l3.is_some(),\n      \"should successfully acquire lease after it was released\"\n    );\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/lib.rs",
    "content": "pub mod awareness_gossip;\npub mod client;\npub mod collab_update_sink;\npub mod error;\npub mod lease;\npub mod metrics;\npub mod model;\npub mod stream_router;\n"
  },
  {
    "path": "libs/collab-stream/src/metrics.rs",
    "content": "use prometheus_client::metrics::counter::Counter;\nuse prometheus_client::registry::Registry;\n\n#[derive(Default)]\npub struct CollabStreamMetrics {\n  /// Incremented each time a new collab stream read task is set (including recurring tasks).\n  pub reads_enqueued: Counter,\n  /// Incremented each time an existing task is consumed (including recurring tasks).\n  pub reads_dequeued: Counter,\n}\n\nimpl CollabStreamMetrics {\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::default();\n    let realtime_registry = registry.sub_registry_with_prefix(\"collab_stream\");\n    realtime_registry.register(\n      \"reads_enqueued\",\n      \"Incremented each time a new collab stream read task is set (including recurring tasks).\",\n      metrics.reads_enqueued.clone(),\n    );\n    realtime_registry.register(\n      \"reads_dequeued\",\n      \"Incremented each time an existing task is consumed (including recurring tasks).\",\n      metrics.reads_dequeued.clone(),\n    );\n    metrics\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/model.rs",
    "content": "use crate::error::{internal, StreamError};\nuse crate::stream_router::{FromRedisStream, RedisMap};\nuse anyhow::anyhow;\nuse appflowy_proto::Rid;\nuse bytes::Bytes;\nuse collab::core::awareness::AwarenessUpdate;\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::updates::decoder::Decode;\nuse collab_entity::CollabType;\nuse redis::streams::StreamId;\nuse redis::{cmd, Cmd, FromRedisValue, RedisError, RedisResult, RedisWrite, ToRedisArgs, Value};\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fmt::{Display, Formatter};\nuse std::ops::Deref;\nuse std::str::FromStr;\nuse uuid::Uuid;\n\n/// The [MessageId] generated by XADD has two parts: a timestamp and a sequence number, separated by\n/// a hyphen (-). The timestamp is based on the server's time when the message is added, and the\n/// sequence number is used to differentiate messages added at the same millisecond.\n///\n///  If multiple messages are added within the same millisecond, Redis increments the sequence number\n/// for each subsequent message\n///\n/// An example message ID might look like this: 1631020452097-0. In this example, 1631020452097 is\n/// the timestamp in milliseconds, and 0 is the sequence number.\n#[derive(Debug, Copy, Clone, Default, Ord, PartialOrd, Eq, PartialEq)]\npub struct MessageId {\n  pub timestamp_ms: u64,\n  pub sequence_number: u16,\n}\n\nimpl MessageId {\n  pub fn new(timestamp_ms: u64, sequence_number: u16) -> Self {\n    MessageId {\n      timestamp_ms,\n      sequence_number,\n    }\n  }\n}\n\nimpl Display for MessageId {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(f, \"{}-{}\", self.timestamp_ms, self.sequence_number)\n  }\n}\n\nimpl From<Rid> for MessageId {\n  fn from(rid: Rid) -> Self {\n    MessageId {\n      timestamp_ms: rid.timestamp,\n      sequence_number: rid.seq_no,\n    }\n  }\n}\n\nimpl From<MessageId> for Rid {\n  fn from(val: MessageId) -> Self {\n    Rid {\n      timestamp: val.timestamp_ms,\n      seq_no: val.sequence_number,\n    }\n  }\n}\n\nimpl TryFrom<&[u8]> for MessageId {\n  type Error = StreamError;\n\n  fn try_from(s: &[u8]) -> Result<Self, Self::Error> {\n    let s = std::str::from_utf8(s)?;\n    Self::try_from(s)\n  }\n}\n\nimpl TryFrom<&str> for MessageId {\n  type Error = StreamError;\n\n  fn try_from(s: &str) -> Result<Self, Self::Error> {\n    let parts: Vec<_> = s.splitn(2, '-').collect();\n\n    if parts.len() != 2 {\n      return Err(StreamError::InvalidFormat);\n    }\n\n    // Directly parse without intermediate assignment.\n    let timestamp_ms = u64::from_str(parts[0])?;\n    let sequence_number = u16::from_str(parts[1])?;\n\n    Ok(MessageId {\n      timestamp_ms,\n      sequence_number,\n    })\n  }\n}\n\nimpl TryFrom<String> for MessageId {\n  type Error = StreamError;\n\n  fn try_from(s: String) -> Result<Self, Self::Error> {\n    Self::try_from(s.as_str())\n  }\n}\n\nimpl FromRedisValue for MessageId {\n  fn from_redis_value(v: &Value) -> RedisResult<Self> {\n    match v {\n      Value::BulkString(stream_key) => MessageId::try_from(stream_key.as_slice()).map_err(|_| {\n        RedisError::from((\n          redis::ErrorKind::TypeError,\n          \"invalid stream key\",\n          format!(\"{:?}\", stream_key),\n        ))\n      }),\n      _ => Err(internal(\"expecting Value::Data\")),\n    }\n  }\n}\n\nimpl ToRedisArgs for MessageId {\n  fn write_redis_args<W>(&self, out: &mut W)\n  where\n    W: ?Sized + RedisWrite,\n  {\n    out.write_arg_fmt(self)\n  }\n}\n\n/// A message in the Redis stream. It's the same as [StreamBinary] but with additional metadata.\n#[derive(Debug, Clone)]\npub struct StreamMessage {\n  pub data: Bytes,\n  /// only applicable when reading from redis\n  pub id: MessageId,\n}\n\nimpl FromRedisValue for StreamMessage {\n  // Optimized parsing function\n  fn from_redis_value(v: &Value) -> RedisResult<Self> {\n    let bulk = bulk_from_redis_value(v)?;\n    if bulk.len() != 2 {\n      return Err(RedisError::from((\n        redis::ErrorKind::TypeError,\n        \"Invalid length\",\n        format!(\n          \"Expected length of 2 for the outer bulk value, but got:{}\",\n          bulk.len()\n        ),\n      )));\n    }\n\n    let id = MessageId::from_redis_value(&bulk[0])?;\n    let fields = bulk_from_redis_value(&bulk[1])?;\n    if fields.len() != 2 {\n      return Err(RedisError::from((\n        redis::ErrorKind::TypeError,\n        \"Invalid length\",\n        format!(\n          \"Expected length of 2 for the bulk value, but got {}\",\n          fields.len()\n        ),\n      )));\n    }\n\n    verify_field(&fields[0], \"data\")?;\n    let raw_data = Vec::<u8>::from_redis_value(&fields[1])?;\n\n    Ok(StreamMessage {\n      data: Bytes::from(raw_data),\n      id,\n    })\n  }\n}\n\nimpl TryFrom<StreamId> for StreamMessage {\n  type Error = StreamError;\n\n  fn try_from(value: StreamId) -> Result<Self, Self::Error> {\n    let id = MessageId::try_from(value.id.as_str())?;\n    let data = value\n      .get(\"data\")\n      .ok_or(StreamError::UnexpectedValue(\"data\".to_string()))?;\n    Ok(Self { data, id })\n  }\n}\n\n#[derive(Debug)]\npub struct StreamBinary(pub Vec<u8>);\n\nimpl From<StreamMessage> for StreamBinary {\n  fn from(m: StreamMessage) -> Self {\n    Self(m.data.to_vec())\n  }\n}\n\nimpl Deref for StreamBinary {\n  type Target = Vec<u8>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl StreamBinary {\n  pub fn into_tuple_array(self) -> [(&'static str, Vec<u8>); 1] {\n    static DATA: &str = \"data\";\n    [(DATA, self.0)]\n  }\n}\n\nimpl TryFrom<Vec<u8>> for StreamBinary {\n  type Error = StreamError;\n\n  fn try_from(value: Vec<u8>) -> Result<Self, Self::Error> {\n    Ok(Self(value))\n  }\n}\n\nimpl TryFrom<&[u8]> for StreamBinary {\n  type Error = StreamError;\n\n  fn try_from(value: &[u8]) -> Result<Self, Self::Error> {\n    Ok(Self(value.to_vec()))\n  }\n}\n\nfn verify_field(field: &Value, expected: &str) -> RedisResult<()> {\n  let field_str = String::from_redis_value(field)?;\n  if field_str != expected {\n    return Err(RedisError::from((\n      redis::ErrorKind::TypeError,\n      \"Invalid field\",\n      format!(\"Expected '{}', found '{}'\", expected, field_str),\n    )));\n  }\n  Ok(())\n}\n\npub struct RedisString(String);\nimpl FromRedisValue for RedisString {\n  fn from_redis_value(v: &Value) -> RedisResult<Self> {\n    match v {\n      Value::BulkString(bytes) => Ok(RedisString(String::from_utf8(bytes.to_vec())?)),\n      Value::SimpleString(str) => Ok(RedisString(str.clone())),\n      _ => Err(internal(\"expecting Value::Data\")),\n    }\n  }\n}\n\nimpl Display for RedisString {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(f, \"{}\", self.0.clone())\n  }\n}\n\nfn bulk_from_redis_value(v: &Value) -> Result<&Vec<Value>, RedisError> {\n  match v {\n    Value::Array(b) => Ok(b),\n    Value::Set(b) => Ok(b),\n    _ => Err(internal(\"expecting Value::Bulk\")),\n  }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]\npub enum CollabControlEvent {\n  Open {\n    workspace_id: String,\n    object_id: String,\n    collab_type: CollabType,\n    doc_state: Vec<u8>,\n  },\n  Close {\n    object_id: String,\n  },\n}\n\nimpl Display for CollabControlEvent {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    match self {\n      CollabControlEvent::Open {\n        workspace_id: _,\n        object_id,\n        collab_type,\n        doc_state: _,\n      } => f.write_fmt(format_args!(\n        \"Open collab: object_id:{}|collab_type:{:?}\",\n        object_id, collab_type,\n      )),\n      CollabControlEvent::Close { object_id } => {\n        f.write_fmt(format_args!(\"Close collab: object_id:{}\", object_id))\n      },\n    }\n  }\n}\n\nimpl CollabControlEvent {\n  pub fn encode(&self) -> Result<Vec<u8>, serde_json::Error> {\n    serde_json::to_vec(self)\n  }\n\n  pub fn decode(data: &[u8]) -> Result<Self, serde_json::Error> {\n    serde_json::from_slice(data)\n  }\n}\n\nimpl TryFrom<CollabControlEvent> for StreamBinary {\n  type Error = StreamError;\n\n  fn try_from(value: CollabControlEvent) -> Result<Self, Self::Error> {\n    let raw_data = value.encode()?;\n    Ok(StreamBinary(raw_data))\n  }\n}\n\npub struct CollabStreamUpdate {\n  pub data: Vec<u8>, // yrs::Update::encode_v1\n  pub sender: CollabOrigin,\n  pub flags: UpdateFlags,\n}\n\nimpl CollabStreamUpdate {\n  pub fn new<B, F>(data: B, sender: CollabOrigin, flags: F) -> Self\n  where\n    B: Into<Vec<u8>>,\n    F: Into<UpdateFlags>,\n  {\n    CollabStreamUpdate {\n      data: data.into(),\n      sender,\n      flags: flags.into(),\n    }\n  }\n\n  pub fn into_update(self) -> Result<collab::preclude::Update, StreamError> {\n    let bytes = if self.flags.is_compressed() {\n      zstd::decode_all(std::io::Cursor::new(self.data))?\n    } else {\n      self.data\n    };\n    let update = if self.flags.is_v1_encoded() {\n      collab::preclude::Update::decode_v1(&bytes)?\n    } else {\n      collab::preclude::Update::decode_v2(&bytes)?\n    };\n    Ok(update)\n  }\n}\n\nimpl From<UpdateStreamMessage> for CollabStreamUpdate {\n  fn from(value: UpdateStreamMessage) -> Self {\n    CollabStreamUpdate {\n      data: value.update.to_vec(),\n      sender: value.sender,\n      flags: value.update_flags.into(),\n    }\n  }\n}\n\nimpl<'a> TryFrom<&'a HashMap<String, redis::Value>> for CollabStreamUpdate {\n  type Error = StreamError;\n\n  fn try_from(fields: &'a HashMap<String, Value>) -> Result<Self, Self::Error> {\n    let sender = match fields.get(\"sender\") {\n      None => CollabOrigin::Empty,\n      Some(sender) => {\n        let raw_origin = String::from_redis_value(sender)?;\n        CollabOrigin::from_str(&raw_origin)\n          .map_err(|e| StreamError::UnexpectedValue(e.to_string()))?\n      },\n    };\n    let flags = match fields.get(\"flags\") {\n      None => UpdateFlags::default(),\n      Some(flags) => u8::from_redis_value(flags).unwrap_or(0).into(),\n    };\n    let data_raw = fields\n      .get(\"data\")\n      .ok_or_else(|| internal(\"expecting field `data`\"))?;\n    let data: Vec<u8> = FromRedisValue::from_redis_value(data_raw)?;\n    Ok(CollabStreamUpdate {\n      data,\n      sender,\n      flags,\n    })\n  }\n}\n\nimpl FromRedisStream for (MessageId, CollabStreamUpdate) {\n  type Error = StreamError;\n\n  fn from_redis_stream(msg_id: &str, fields: &RedisMap) -> Result<Self, Self::Error>\n  where\n    Self: Sized,\n  {\n    let message_id = MessageId::try_from(msg_id)?;\n    let stream_update = CollabStreamUpdate::try_from(fields)?;\n    Ok((message_id, stream_update))\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct AwarenessStreamUpdate {\n  pub data: AwarenessUpdate,\n  pub sender: CollabOrigin,\n}\n\n#[repr(transparent)]\n#[derive(Copy, Clone, Eq, PartialEq, Default)]\npub struct UpdateFlags(u8);\n\nimpl From<appflowy_proto::UpdateFlags> for UpdateFlags {\n  fn from(flags: appflowy_proto::UpdateFlags) -> Self {\n    match flags {\n      appflowy_proto::UpdateFlags::Lib0v1 => UpdateFlags(0),\n      appflowy_proto::UpdateFlags::Lib0v2 => UpdateFlags(Self::IS_V2_ENCODED),\n    }\n  }\n}\n\nimpl UpdateFlags {\n  /// Flag bit to mark if update is encoded using [EncoderV2] (if set) or [EncoderV1] (if clear).\n  pub const IS_V2_ENCODED: u8 = 0b0000_0001;\n  /// Flag bit to mark if update is compressed.\n  pub const IS_COMPRESSED: u8 = 0b0000_0010;\n\n  #[inline]\n  pub fn is_v2_encoded(&self) -> bool {\n    self.0 & Self::IS_V2_ENCODED != 0\n  }\n\n  #[inline]\n  pub fn is_v1_encoded(&self) -> bool {\n    !self.is_v2_encoded()\n  }\n\n  #[inline]\n  pub fn is_compressed(&self) -> bool {\n    self.0 & Self::IS_COMPRESSED != 0\n  }\n}\n\nimpl ToRedisArgs for UpdateFlags {\n  #[inline]\n  fn write_redis_args<W>(&self, out: &mut W)\n  where\n    W: ?Sized + RedisWrite,\n  {\n    self.0.write_redis_args(out)\n  }\n}\n\nimpl From<u8> for UpdateFlags {\n  #[inline]\n  fn from(value: u8) -> Self {\n    UpdateFlags(value)\n  }\n}\n\nimpl Display for UpdateFlags {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    if !self.is_v2_encoded() {\n      write!(f, \".v1\")?;\n    } else {\n      write!(f, \".v2\")?;\n    }\n\n    if self.is_compressed() {\n      write!(f, \".zstd\")?;\n    }\n\n    Ok(())\n  }\n}\n\n#[derive(Debug, PartialEq)]\npub struct UpdateStreamMessage {\n  pub last_message_id: Rid,\n  pub sender: CollabOrigin,\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n  pub update_flags: appflowy_proto::UpdateFlags,\n  pub update: Bytes,\n}\n\nimpl UpdateStreamMessage {\n  pub fn stream_key(workspace_id: &Uuid) -> String {\n    format!(\"af:u:{}\", workspace_id)\n  }\n\n  pub fn prepare_command(\n    stream_key: &str,\n    object_id: &Uuid,\n    collab_type: CollabType,\n    sender: &CollabOrigin,\n    update: Vec<u8>,\n    flag: UpdateFlags,\n  ) -> Cmd {\n    let mut cmd = cmd(\"XADD\");\n    cmd\n      .arg(stream_key)\n      .arg(\"*\")\n      .arg(\"oid\")\n      .arg(object_id)\n      .arg(\"ct\")\n      .arg(collab_type as i32)\n      .arg(\"sender\")\n      .arg(sender.to_string())\n      .arg(\"data\")\n      .arg(update.to_vec())\n      .arg(\"flags\")\n      .arg(flag);\n    cmd\n  }\n}\n\nimpl FromRedisStream for UpdateStreamMessage {\n  type Error = anyhow::Error;\n  fn from_redis_stream(msg_id: &str, fields: &RedisMap) -> Result<Self, Self::Error> {\n    let last_message_id = Rid::from_str(msg_id).map_err(|err| anyhow!(\"{}\", err))?;\n    let object_id = fields\n      .get(\"oid\")\n      .ok_or_else(|| anyhow!(\"expecting field `oid`\"))?;\n    let object_id = Uuid::from_redis_value(object_id).map_err(|err| anyhow!(\"{}\", err))?;\n    let collab_type = fields\n      .get(\"ct\")\n      .ok_or_else(|| anyhow!(\"expecting field `ct`\"))?;\n    let collab_type = CollabType::from(i32::from_redis_value(collab_type)?);\n    let sender = fields\n      .get(\"sender\")\n      .ok_or_else(|| anyhow!(\"expecting field `sender`\"))?;\n    let sender = CollabOrigin::from_str(&String::from_redis_value(sender)?)?;\n    let update_flags = match fields.get(\"flags\") {\n      None => appflowy_proto::UpdateFlags::default(),\n      Some(flags) => u8::from_redis_value(flags).unwrap_or(0).try_into()?,\n    };\n    let update = fields\n      .get(\"data\")\n      .ok_or_else(|| anyhow!(\"expecting field `data`\"))?;\n    let update: Bytes = FromRedisValue::from_redis_value(update)?;\n    Ok(UpdateStreamMessage {\n      last_message_id,\n      sender,\n      object_id,\n      collab_type,\n      update_flags,\n      update,\n    })\n  }\n}\n"
  },
  {
    "path": "libs/collab-stream/src/stream_router.rs",
    "content": "use crate::metrics::CollabStreamMetrics;\nuse dashmap::mapref::entry::Entry;\nuse dashmap::DashMap;\nuse futures::Stream;\nuse loole::{Receiver, Sender};\nuse redis::streams::{StreamReadOptions, StreamReadReply};\nuse redis::Client;\nuse redis::Commands;\nuse redis::Connection;\nuse redis::RedisError;\nuse redis::RedisResult;\nuse redis::Value;\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse std::pin::Pin;\nuse std::sync::atomic::AtomicBool;\nuse std::sync::atomic::Ordering::SeqCst;\nuse std::sync::Arc;\nuse std::task::{Context, Poll};\nuse std::thread::{sleep, JoinHandle};\nuse std::time::Duration;\nuse tokio_stream::wrappers::BroadcastStream;\n\n/// Redis stream key.\npub type StreamKey = String;\n\npub trait FromRedisStream {\n  type Error: Display;\n  fn from_redis_stream(msg_id: &str, fields: &RedisMap) -> Result<Self, Self::Error>\n  where\n    Self: Sized;\n}\n\n/// Channel returned by [StreamRouter::observe], that allows to receive messages retrieved by\n/// the router.\npub struct StreamReader<T> {\n  receiver: BroadcastStream<Arc<(String, RedisMap)>>,\n  _phantom: std::marker::PhantomData<T>,\n}\n\nimpl<T> StreamReader<T> {\n  pub fn new(receiver: StreamReceiver) -> Self {\n    Self {\n      receiver: BroadcastStream::new(receiver),\n      _phantom: std::marker::PhantomData,\n    }\n  }\n}\n\nimpl<T> Stream for StreamReader<T>\nwhere\n  T: FromRedisStream,\n{\n  type Item = Result<T, T::Error>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let pin = unsafe { self.map_unchecked_mut(|v| &mut v.receiver) };\n    match pin.poll_next(cx) {\n      Poll::Ready(Some(Ok(msg))) => {\n        let result = T::from_redis_stream(&msg.0, &msg.1);\n        Poll::Ready(Some(result))\n      },\n      Poll::Ready(Some(Err(err))) => {\n        tracing::error!(\"failed to receive message: {}\", err);\n        Poll::Ready(None)\n      },\n      Poll::Ready(None) => Poll::Ready(None),\n      Poll::Pending => Poll::Pending,\n    }\n  }\n}\n\n/// Redis stream router used to multiplex multiple number of Redis stream read requests over a\n/// fixed number of Redis connections.\npub struct StreamRouter {\n  buf: Sender<StreamHandle>,\n  alive: Arc<AtomicBool>,\n  #[allow(dead_code)]\n  workers: Vec<Worker>,\n  metrics: Arc<CollabStreamMetrics>,\n  channels: Arc<DashMap<StreamKey, WeakStreamSender>>,\n  buffer_capacity: usize,\n}\n\nimpl StreamRouter {\n  pub fn new(client: &Client, metrics: Arc<CollabStreamMetrics>) -> Result<Self, RedisError> {\n    Self::with_options(client, metrics, Default::default())\n  }\n\n  pub fn with_options(\n    client: &Client,\n    metrics: Arc<CollabStreamMetrics>,\n    options: StreamRouterOptions,\n  ) -> Result<Self, RedisError> {\n    let alive = Arc::new(AtomicBool::new(true));\n    let (tx, rx) = loole::unbounded();\n    let channels = Arc::new(DashMap::new());\n    let mut workers = Vec::with_capacity(options.worker_count);\n    for worker_id in 0..options.worker_count {\n      let conn = client.get_connection()?;\n      let worker = Worker::new(\n        worker_id,\n        conn,\n        tx.clone(),\n        rx.clone(),\n        alive.clone(),\n        &options,\n        metrics.clone(),\n        channels.clone(),\n      );\n      workers.push(worker);\n    }\n    tracing::info!(\"stared Redis stream router with {} workers\", workers.len());\n    Ok(Self {\n      buf: tx,\n      workers,\n      alive,\n      metrics,\n      channels,\n      buffer_capacity: options.xread_count.unwrap_or(10_000),\n    })\n  }\n\n  pub fn observe<T: FromRedisStream>(\n    &self,\n    stream_key: StreamKey,\n    last_id: Option<String>,\n  ) -> StreamReader<T> {\n    let rx = match self.channels.entry(stream_key.clone()) {\n      Entry::Vacant(e) => {\n        tracing::trace!(\"creating new stream channel for {}\", e.key());\n        let (tx, rx) = tokio::sync::broadcast::channel(self.buffer_capacity);\n        e.insert(tx.downgrade());\n        let last_id = last_id.unwrap_or_else(|| \"0\".to_string());\n        let h = StreamHandle::new(stream_key.clone(), last_id, tx);\n        self.buf.send(h).unwrap();\n        self.metrics.reads_enqueued.inc();\n        rx\n      },\n      Entry::Occupied(mut e) => {\n        let sender = e.get();\n        if let Some(sender) = sender.upgrade() {\n          tracing::trace!(\"reusing existing stream channel for {}\", e.key());\n          sender.subscribe()\n        } else {\n          tracing::trace!(\"creating new stream channel for {}\", e.key());\n          let (tx, rx) = tokio::sync::broadcast::channel(self.buffer_capacity);\n          e.insert(tx.downgrade());\n          let last_id = last_id.unwrap_or_else(|| \"0\".to_string());\n          let h = StreamHandle::new(stream_key.clone(), last_id, tx);\n          self.buf.send(h).unwrap();\n          self.metrics.reads_enqueued.inc();\n          rx\n        }\n      },\n    };\n    StreamReader::new(rx)\n  }\n}\n\nimpl Drop for StreamRouter {\n  fn drop(&mut self) {\n    self.alive.store(false, SeqCst);\n  }\n}\n\n/// Options used to configure [StreamRouter].\n#[derive(Debug, Clone)]\npub struct StreamRouterOptions {\n  /// Number of worker threads. Each worker thread has its own Redis connection.\n  /// Default: number of CPU threads but can vary under specific circumstances.\n  pub worker_count: usize,\n  /// How many Redis streams a single Redis poll worker can read at a time.\n  /// Default: 100\n  pub xread_streams: usize,\n  /// How long poll worker will be blocked while waiting for Redis `XREAD` request to respond.\n  /// This blocks a worker thread and doesn't affect other threads.\n  ///\n  /// If set to `None` it won't block and will return immediately, which gives a biggest\n  /// responsiveness but can lead to unnecessary active loops causing CPU spikes even when idle.\n  ///\n  /// Default: `Some(0)` meaning blocking for indefinite amount of time.\n  pub xread_block_millis: Option<usize>,\n  /// How many messages a single worker's `XREAD` request is allowed to return.\n  /// Default: `None` (unbounded).\n  pub xread_count: Option<usize>,\n}\n\nimpl Default for StreamRouterOptions {\n  fn default() -> Self {\n    StreamRouterOptions {\n      worker_count: std::thread::available_parallelism().unwrap().get(),\n      xread_streams: 100,\n      xread_block_millis: Some(0),\n      xread_count: None,\n    }\n  }\n}\n\nstruct Worker {\n  _handle: JoinHandle<()>,\n}\n\nimpl Worker {\n  #[allow(clippy::too_many_arguments)]\n  fn new(\n    worker_id: usize,\n    conn: Connection,\n    tx: Sender<StreamHandle>,\n    rx: Receiver<StreamHandle>,\n    alive: Arc<AtomicBool>,\n    options: &StreamRouterOptions,\n    metrics: Arc<CollabStreamMetrics>,\n    channels: Arc<DashMap<StreamKey, WeakStreamSender>>,\n  ) -> Self {\n    let mut xread_options = StreamReadOptions::default();\n    if let Some(block_millis) = options.xread_block_millis {\n      xread_options = xread_options.block(block_millis);\n    }\n    if let Some(count) = options.xread_count {\n      xread_options = xread_options.count(count);\n    }\n    let count = options.xread_streams;\n    let handle = std::thread::spawn(move || {\n      if let Err(err) =\n        Self::process_streams(conn, tx, rx, alive, xread_options, count, metrics, channels)\n      {\n        tracing::error!(\"worker {} failed: {}\", worker_id, err);\n      }\n    });\n    Self { _handle: handle }\n  }\n\n  #[allow(clippy::too_many_arguments)]\n  fn process_streams(\n    mut conn: Connection,\n    tx: Sender<StreamHandle>,\n    rx: Receiver<StreamHandle>,\n    alive: Arc<AtomicBool>,\n    options: StreamReadOptions,\n    count: usize,\n    metrics: Arc<CollabStreamMetrics>,\n    channels: Arc<DashMap<StreamKey, WeakStreamSender>>,\n  ) -> RedisResult<()> {\n    let mut stream_keys = Vec::with_capacity(count);\n    let mut message_ids = Vec::with_capacity(count);\n    let mut senders = HashMap::with_capacity(count);\n    while alive.load(SeqCst) {\n      // receive next `count` of stream read requests\n      if !Self::read_buf(&rx, &mut stream_keys, &mut message_ids, &mut senders) {\n        break; // rx channel has closed\n      }\n\n      let key_count = stream_keys.len();\n      if key_count == 0 {\n        tracing::warn!(\"Bug: read empty buf\");\n        sleep(Duration::from_millis(100));\n        continue;\n      }\n\n      metrics.reads_dequeued.inc_by(key_count as u64);\n      let result: StreamReadReply = conn.xread_options(&stream_keys, &message_ids, &options)?;\n\n      let mut msgs = 0;\n      for stream in result.keys {\n        // for each stream returned from Redis, resolve corresponding subscriber and send messages\n        let mut remove_sender = false;\n        if let Some((sender, idx)) = senders.get(stream.key.as_str()) {\n          for id in stream.ids {\n            let message_id = id.id;\n            let value = id.map;\n            message_ids[*idx].clone_from(&message_id); //TODO: optimize\n            msgs += 1;\n            if let Err(err) = sender.send(Arc::new((message_id, value))) {\n              tracing::debug!(\"failed to send: {}\", err);\n              remove_sender = true;\n            }\n          }\n        }\n\n        if remove_sender {\n          channels.remove(&stream.key);\n          senders.remove(stream.key.as_str());\n        }\n      }\n\n      if msgs > 0 {\n        tracing::trace!(\n          \"XREAD: read total of {} messages for {} streams\",\n          msgs,\n          key_count\n        );\n      }\n      let scheduled = Self::schedule_back(\n        &tx,\n        &mut stream_keys,\n        &mut message_ids,\n        &mut senders,\n        &channels,\n      );\n      metrics.reads_enqueued.inc_by(scheduled as u64);\n    }\n    Ok(())\n  }\n\n  fn schedule_back(\n    tx: &Sender<StreamHandle>,\n    keys: &mut Vec<StreamKey>,\n    ids: &mut Vec<String>,\n    senders: &mut HashMap<&str, (StreamSender, usize)>,\n    channels: &DashMap<StreamKey, WeakStreamSender>,\n  ) -> usize {\n    let keys = keys.drain(..);\n    let mut ids = ids.drain(..);\n    let mut scheduled = 0;\n    for key in keys {\n      if let Some(last_id) = ids.next() {\n        if let Some((sender, _)) = senders.remove(key.as_str()) {\n          if sender.receiver_count() == 0 {\n            channels.remove(key.as_str());\n            tracing::trace!(\"no subscribers for {}, removing channel\", key);\n            continue; // sender is already closed\n          }\n          let h = StreamHandle::new(key, last_id, sender);\n          if let Err(err) = tx.send(h) {\n            tracing::error!(\"failed to reschedule: {}\", err);\n            break;\n          }\n          scheduled += 1;\n        }\n      }\n    }\n    senders.clear();\n    scheduled\n  }\n\n  fn read_buf(\n    rx: &Receiver<StreamHandle>,\n    stream_keys: &mut Vec<StreamKey>,\n    message_ids: &mut Vec<String>,\n    senders: &mut HashMap<&'static str, (StreamSender, usize)>,\n  ) -> bool {\n    // try to receive first element - block thread if there's none\n    let mut count = stream_keys.capacity();\n    if let Ok(h) = rx.recv() {\n      // senders and stream_keys have bound lifetimes and fixed internal buffers\n      // since API users are using StreamKeys => String, we want to avoid allocations\n      let key_ref: &'static str = unsafe { std::mem::transmute(h.key.as_str()) };\n      senders.insert(key_ref, (h.sender, stream_keys.len()));\n      stream_keys.push(h.key);\n      message_ids.push(h.last_id.to_string());\n\n      count -= 1;\n      if count == 0 {\n        return true;\n      }\n\n      // try to fill more without blocking if there's anything on the receiver\n      while let Ok(h) = rx.try_recv() {\n        let key_ref: &'static str = unsafe { std::mem::transmute(h.key.as_str()) };\n        senders.insert(key_ref, (h.sender, stream_keys.len()));\n        stream_keys.push(h.key);\n        message_ids.push(h.last_id.to_string());\n\n        count -= 1;\n        if count == 0 {\n          return true;\n        }\n      }\n      true\n    } else {\n      false\n    }\n  }\n}\n\npub type RedisMap = HashMap<String, Value>;\ntype WeakStreamSender = tokio::sync::broadcast::WeakSender<Arc<(String, RedisMap)>>;\ntype StreamSender = tokio::sync::broadcast::Sender<Arc<(String, RedisMap)>>;\ntype StreamReceiver = tokio::sync::broadcast::Receiver<Arc<(String, RedisMap)>>;\n\nstruct StreamHandle {\n  key: StreamKey,\n  last_id: String,\n  sender: StreamSender,\n}\n\nimpl StreamHandle {\n  fn new(key: StreamKey, last_id: String, sender: StreamSender) -> Self {\n    StreamHandle {\n      key,\n      last_id,\n      sender,\n    }\n  }\n}\n\n#[cfg(test)]\nmod test {\n  use crate::metrics::CollabStreamMetrics;\n  use crate::stream_router::{FromRedisStream, RedisMap, StreamRouter, StreamRouterOptions};\n  use futures::StreamExt;\n  use rand::random;\n  use redis::{Client, Commands, FromRedisValue};\n  use std::sync::Arc;\n  use std::time::Duration;\n  use tokio::task::JoinSet;\n  use tokio::time::timeout;\n\n  struct TestMessage {\n    id: String,\n    data: String,\n  }\n\n  impl FromRedisStream for TestMessage {\n    type Error = anyhow::Error;\n\n    fn from_redis_stream(id: &str, fields: &RedisMap) -> Result<Self, Self::Error>\n    where\n      Self: Sized,\n    {\n      let data = fields\n        .get(\"data\")\n        .ok_or_else(|| anyhow::anyhow!(\"expecting field `data`\"))?;\n      let data = String::from_redis_value(data)?;\n      Ok(TestMessage {\n        id: id.to_owned(),\n        data,\n      })\n    }\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  #[ignore = \"flaky test\"]\n  async fn multi_worker_preexisting_messages() {\n    const ROUTES_COUNT: usize = 200;\n    const MSG_PER_ROUTE: usize = 10;\n\n    let test_future = async {\n      let mut client = Client::open(\"redis://127.0.0.1/\").unwrap();\n      let keys = init_streams(&mut client, ROUTES_COUNT, MSG_PER_ROUTE);\n      let metrics = Arc::new(CollabStreamMetrics::default());\n\n      let router = StreamRouter::new(&client, metrics).unwrap();\n      let mut join_set = JoinSet::new();\n      for key in keys {\n        let mut observer = router.observe(key.clone(), None);\n        join_set.spawn(async move {\n          for i in 0..MSG_PER_ROUTE {\n            let msg: TestMessage = observer.next().await.unwrap().unwrap();\n            assert_eq!(msg.data, format!(\"{}-{}\", key, i));\n          }\n        });\n      }\n\n      while let Some(t) = join_set.join_next().await {\n        t.unwrap();\n      }\n    };\n\n    // Add timeout to prevent infinite execution\n    tokio::time::timeout(tokio::time::Duration::from_secs(30), test_future)\n      .await\n      .expect(\"Test timed out after 30 seconds\");\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn multi_worker_live_messages() {\n    const ROUTES_COUNT: usize = 200;\n    const MSG_PER_ROUTE: usize = 10;\n    let mut client = Client::open(\"redis://127.0.0.1/\").unwrap();\n    let keys = init_streams(&mut client, ROUTES_COUNT, 0);\n    let metrics = Arc::new(CollabStreamMetrics::default());\n\n    let router = StreamRouter::new(&client, metrics).unwrap();\n\n    let mut join_set = JoinSet::new();\n    for key in keys.iter() {\n      let mut observer = router.observe(key.clone(), None);\n      let key = key.clone();\n      join_set.spawn(async move {\n        for i in 0..MSG_PER_ROUTE {\n          let msg: TestMessage = observer.next().await.unwrap().unwrap();\n          assert_eq!(msg.data, format!(\"{}-{}\", key, i));\n        }\n      });\n    }\n\n    for msg_idx in 0..MSG_PER_ROUTE {\n      for key in keys.iter() {\n        let data = format!(\"{}-{}\", key, msg_idx);\n        let _: String = client.xadd(key, \"*\", &[(\"data\", data)]).unwrap();\n      }\n    }\n\n    while let Some(t) = join_set.join_next().await {\n      t.unwrap();\n    }\n  }\n\n  #[tokio::test]\n  async fn stream_reader_continue_from() {\n    let mut client = Client::open(\"redis://127.0.0.1/\").unwrap();\n    let key = format!(\"test:{}:{}\", random::<u32>(), 0);\n    let _: String = client.xadd(&key, \"*\", &[(\"data\", \"1\")]).unwrap();\n    let m2: String = client.xadd(&key, \"*\", &[(\"data\", \"2\")]).unwrap();\n    let m3: String = client.xadd(&key, \"*\", &[(\"data\", \"3\")]).unwrap();\n    let metrics = Arc::new(CollabStreamMetrics::default());\n\n    let router = StreamRouter::new(&client, metrics).unwrap();\n    let mut observer = router.observe(key, Some(m2));\n\n    let msg: TestMessage = observer.next().await.unwrap().unwrap();\n    assert_eq!(msg.id, m3);\n    assert_eq!(msg.data, \"3\");\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn drop_subscription() {\n    const ROUTES_COUNT: usize = 1;\n    const MSG_PER_ROUTE: usize = 10;\n\n    let mut client = Client::open(\"redis://127.0.0.1/\").unwrap();\n    let mut keys = init_streams(&mut client, ROUTES_COUNT, MSG_PER_ROUTE);\n    let metrics = Arc::new(CollabStreamMetrics::default());\n\n    let router = StreamRouter::with_options(\n      &client,\n      metrics.clone(),\n      StreamRouterOptions {\n        worker_count: 2,\n        xread_streams: 100,\n        xread_block_millis: Some(50),\n        xread_count: None,\n      },\n    )\n    .unwrap();\n\n    let key = keys.pop().unwrap();\n    let mut observer = router.observe(key.clone(), None);\n    for i in 0..MSG_PER_ROUTE {\n      let msg: TestMessage = observer.next().await.unwrap().unwrap();\n      assert_eq!(msg.data, format!(\"{}-{}\", key, i));\n    }\n    // drop observer and wait for worker to release\n    drop(observer);\n    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;\n    let enqueued = metrics.reads_enqueued.get();\n    let dequeued = metrics.reads_dequeued.get();\n    assert_eq!(enqueued, dequeued, \"dropped observer state\");\n\n    // after dropping observer, no new polling task should be rescheduled\n    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;\n    assert_eq!(metrics.reads_enqueued.get(), enqueued, \"unchanged enqueues\");\n    assert_eq!(metrics.reads_dequeued.get(), dequeued, \"unchanged dequeues\");\n\n    tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;\n    assert_eq!(metrics.reads_enqueued.get(), enqueued, \"unchanged enqueues\");\n    assert_eq!(metrics.reads_dequeued.get(), dequeued, \"unchanged dequeues\");\n\n    assert!(router.channels.get(&key).is_none());\n  }\n\n  #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n  async fn observe_unobserve_observe_again() {\n    const ROUTES_COUNT: usize = 1;\n    const MSG_PER_ROUTE: usize = 10;\n\n    let mut client = Client::open(\"redis://127.0.0.1/\").unwrap();\n    let mut keys = init_streams(&mut client, ROUTES_COUNT, MSG_PER_ROUTE);\n    let metrics = Arc::new(CollabStreamMetrics::default());\n\n    let router = StreamRouter::with_options(\n      &client,\n      metrics.clone(),\n      StreamRouterOptions {\n        worker_count: 2,\n        xread_streams: 100,\n        xread_block_millis: Some(50),\n        xread_count: Some(MSG_PER_ROUTE / 2),\n      },\n    )\n    .unwrap();\n\n    let key = keys.pop().unwrap();\n    let mut observer = router.observe(key.clone(), None);\n    // read half of the messages\n    for i in 0..MSG_PER_ROUTE {\n      let msg: TestMessage = observer.next().await.unwrap().unwrap();\n      assert_eq!(msg.data, format!(\"{}-{}\", key, i));\n    }\n    drop(observer);\n\n    // try to overflow the tokio broadcast buffer by producing more messages\n    for i in 0..MSG_PER_ROUTE {\n      let data = format!(\"{}-{}\", key, i);\n      let _: String = client.xadd(&key, \"*\", &[(\"data\", data)]).unwrap();\n    }\n\n    let mut observer = router.observe(key.clone(), None);\n    let t = Duration::from_millis(100);\n    for i in 0..MSG_PER_ROUTE {\n      let msg: TestMessage = timeout(t, observer.next()).await.unwrap().unwrap().unwrap();\n      assert_eq!(msg.data, format!(\"{}-{}\", key, i));\n    }\n  }\n\n  fn init_streams(client: &mut Client, stream_count: usize, msgs_per_stream: usize) -> Vec<String> {\n    let test_prefix: u32 = random();\n    let mut keys = Vec::with_capacity(stream_count);\n    for worker_idx in 0..stream_count {\n      let key = format!(\"test:{}:{}\", test_prefix, worker_idx);\n      for msg_idx in 0..msgs_per_stream {\n        let data = format!(\"{}-{}\", key, msg_idx);\n        let _: String = client.xadd(&key, \"*\", &[(\"data\", data)]).unwrap();\n      }\n      keys.push(key);\n    }\n    keys\n  }\n}\n"
  },
  {
    "path": "libs/database/Cargo.toml",
    "content": "[package]\nname = \"database\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\ncollab = { workspace = true }\ncollab-entity = { workspace = true }\ndatabase-entity.workspace = true\nshared-entity.workspace = true\napp-error = { workspace = true, features = [\"sqlx_error\", \"validation_error\"] }\n\ntokio = { workspace = true, features = [\"sync\"] }\nasync-trait.workspace = true\nanyhow.workspace = true\nserde.workspace = true\nserde_json.workspace = true\ntonic-proto.workspace = true\nappflowy-proto = { workspace = true }\n\nsqlx = { workspace = true, default-features = false, features = [\n  \"postgres\",\n  \"chrono\",\n  \"uuid\",\n  \"macros\",\n  \"runtime-tokio-rustls\",\n  \"rust_decimal\",\n] }\npgvector = { workspace = true, features = [\"sqlx\"] }\ntracing = { version = \"0.1.40\" }\nuuid = { workspace = true, features = [\"serde\", \"v4\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nfutures-util = \"0.3.30\"\naws-sdk-s3 = { version = \"1.88.0\", features = [\n  \"behavior-version-latest\",\n  \"rt-tokio\",\n], optional = true }\nrust_decimal = \"1.36.0\"\nitertools = \"0.12.1\"\n\n[features]\ndefault = [\"s3\"]\ns3 = [\"aws-sdk-s3\"]\n"
  },
  {
    "path": "libs/database/src/access_request.rs",
    "content": "use crate::pg_row::{\n  AFAccessRequestStatusColumn, AFAccessRequestWithViewIdColumn, AFAccessRequesterColumn,\n  AFWorkspaceWithMemberCountRow,\n};\nuse app_error::AppError;\nuse database_entity::dto::AccessRequestWithViewId;\nuse sqlx::{Executor, Postgres};\nuse uuid::Uuid;\n\npub async fn insert_new_access_request<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: Uuid,\n  view_id: Uuid,\n  uid: i64,\n) -> Result<Uuid, AppError> {\n  let request_id_result = sqlx::query_scalar!(\n    r#\"\n      INSERT INTO af_access_request (\n        workspace_id,\n        view_id,\n        uid,\n        status\n      )\n      VALUES ($1, $2, $3, $4)\n      RETURNING request_id\n    \"#,\n    workspace_id,\n    view_id,\n    uid,\n    AFAccessRequestStatusColumn::Pending as _,\n  )\n  .fetch_one(executor)\n  .await;\n  match request_id_result {\n    Err(e)\n      if e\n        .as_database_error()\n        .is_some_and(|e| e.constraint().is_some()) =>\n    {\n      Err(AppError::AccessRequestAlreadyExists {\n        workspace_id,\n        view_id,\n      })\n    },\n    Err(e) => Err(e.into()),\n    Ok(request_id) => Ok(request_id),\n  }\n}\n\npub async fn select_access_request_by_request_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  request_id: Uuid,\n) -> Result<AccessRequestWithViewId, AppError> {\n  let access_request = sqlx::query_as!(\n    AFAccessRequestWithViewIdColumn,\n    r#\"\n      WITH request_id_workspace_member_count AS (\n        SELECT\n          request_id,\n          COUNT(*) AS member_count\n        FROM af_access_request\n        JOIN af_workspace_member USING (workspace_id)\n        WHERE request_id = $1\n        GROUP BY request_id\n      )\n      SELECT\n      request_id,\n      view_id,\n      (\n        workspace_id,\n        af_workspace.database_storage_id,\n        af_workspace.owner_uid,\n        owner_profile.name,\n        owner_profile.email,\n        af_workspace.created_at,\n        af_workspace.workspace_type,\n        af_workspace.deleted_at,\n        af_workspace.workspace_name,\n        af_workspace.icon,\n        request_id_workspace_member_count.member_count\n      ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n      (\n        af_user.uid,\n        af_user.uuid,\n        af_user.name,\n        af_user.email,\n        af_user.metadata ->> 'icon_url'\n      ) AS \"requester!: AFAccessRequesterColumn\",\n      status AS \"status: AFAccessRequestStatusColumn\",\n      af_access_request.created_at AS created_at\n      FROM af_access_request\n      JOIN af_user USING (uid)\n      JOIN af_workspace USING (workspace_id)\n      JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n      JOIN request_id_workspace_member_count USING (request_id)\n      WHERE request_id = $1\n    \"#,\n    request_id,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  let access_request: AccessRequestWithViewId = access_request.try_into()?;\n  Ok(access_request)\n}\n\npub async fn update_access_request_status<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  request_id: Uuid,\n  status: AFAccessRequestStatusColumn,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      UPDATE af_access_request\n      SET status = $2\n      WHERE request_id = $1\n    \"#,\n    request_id,\n    status as _,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn delete_access_request<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  request_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      DELETE FROM af_access_request\n      WHERE request_id = $1\n    \"#,\n    request_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/database/src/chat/chat_ops.rs",
    "content": "use crate::pg_row::AFChatRow;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse chrono::{DateTime, Utc};\nuse shared_entity::dto::chat_dto::{\n  ChatAuthor, ChatAuthorWithUuid, ChatMessage, ChatMessageWithAuthorUuid, ChatSettings,\n  CreateChatParams, GetChatMessageParams, MessageCursor, RepeatedChatMessage,\n  RepeatedChatMessageWithAuthorUuid, UpdateChatMessageContentParams, UpdateChatMessageMetaParams,\n  UpdateChatParams,\n};\n\nuse serde_json::json;\nuse sqlx::postgres::PgArguments;\n\nuse sqlx::{Arguments, Executor, PgPool, Postgres, Transaction};\nuse std::ops::DerefMut;\nuse std::str::FromStr;\nuse tracing::warn;\n\nuse uuid::Uuid;\n\npub async fn insert_chat<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  params: CreateChatParams,\n) -> Result<(), AppError> {\n  let chat_id = Uuid::from_str(&params.chat_id)?;\n  let rag_ids = json!(params.rag_ids);\n  sqlx::query!(\n    r#\"\n       INSERT INTO af_chat (chat_id, name, workspace_id, rag_ids)\n       VALUES ($1, $2, $3, $4)\n    \"#,\n    chat_id,\n    params.name,\n    workspace_id,\n    rag_ids,\n  )\n  .execute(executor)\n  .await\n  .map_err(|err| AppError::Internal(anyhow!(\"Failed to insert chat: {}\", err)))?;\n\n  Ok(())\n}\n\npub async fn select_chat_settings<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  chat_id: &Uuid,\n) -> Result<ChatSettings, AppError> {\n  let row = sqlx::query!(\n    r#\"\n        SELECT name, meta_data, rag_ids\n        FROM af_chat\n        WHERE chat_id = $1 AND deleted_at IS NULL\n    \"#,\n    &chat_id,\n  )\n  .fetch_one(executor)\n  .await?;\n  let rag_ids = serde_json::from_value::<Vec<String>>(row.rag_ids).unwrap_or_default();\n  Ok(ChatSettings {\n    name: row.name,\n    rag_ids,\n    metadata: row.meta_data,\n  })\n}\npub async fn update_chat_settings<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  chat_id: &Uuid,\n  params: UpdateChatParams,\n) -> Result<(), AppError> {\n  let mut query_parts = vec![];\n  let mut args = PgArguments::default();\n  let mut current_param_pos = 1; // Start counting SQL parameters from 1\n\n  if let Some(ref name) = params.name {\n    query_parts.push(format!(\"name = ${}\", current_param_pos));\n    args\n      .add(name)\n      .map_err(|err| AppError::SqlxArgEncodingError {\n        desc: format!(\"unable to encode chat name for chat id {}\", chat_id),\n        err,\n      })?;\n    current_param_pos += 1;\n  }\n\n  if let Some(ref metadata) = params.metadata {\n    query_parts.push(format!(\"meta_data = meta_data || ${}\", current_param_pos));\n    args\n      .add(json!(metadata))\n      .map_err(|err| AppError::SqlxArgEncodingError {\n        desc: format!(\"unable to encode metadata json for chat id {}\", chat_id),\n        err,\n      })?;\n    current_param_pos += 1;\n  }\n\n  if let Some(rag_ids) = params.rag_ids {\n    query_parts.push(format!(\"rag_ids = ${}\", current_param_pos));\n    args\n      .add(json!(rag_ids))\n      .map_err(|err| AppError::SqlxArgEncodingError {\n        desc: format!(\"unable to encode rag ids for chat id {}\", chat_id),\n        err,\n      })?;\n    current_param_pos += 1;\n  }\n\n  if query_parts.is_empty() {\n    // If no fields to update, skip execution\n    return Ok(());\n  }\n\n  let query = format!(\n    \"UPDATE af_chat SET {} WHERE chat_id = ${}\",\n    query_parts.join(\", \"),\n    current_param_pos\n  );\n  args\n    .add(chat_id)\n    .map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode chat id {}\", chat_id),\n      err,\n    })?;\n\n  sqlx::query_with(&query, args)\n    .execute(executor)\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to update chat settings: {}\", err)))?;\n\n  Ok(())\n}\n\npub async fn delete_chat(\n  txn: &mut Transaction<'_, Postgres>,\n  chat_id: &str,\n) -> Result<(), AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  sqlx::query!(\n    r#\"\n        UPDATE af_chat\n        SET deleted_at = now()\n        WHERE chat_id = $1\n    \"#,\n    chat_id,\n  )\n  .execute(txn.deref_mut())\n  .await?;\n  Ok(())\n}\n\npub async fn select_chat<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  chat_id: &str,\n) -> Result<AFChatRow, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let row = sqlx::query_as!(\n    AFChatRow,\n    r#\"\n        SELECT *\n        FROM af_chat\n        WHERE chat_id = $1 AND deleted_at IS NULL\n    \"#,\n    &chat_id,\n  )\n  .fetch_optional(executor)\n  .await?;\n  match row {\n    Some(row) => Ok(row),\n    None => Err(AppError::RecordNotFound(format!(\n      \"chat with given id:{} is not found\",\n      chat_id\n    ))),\n  }\n}\n\npub async fn select_chat_rag_ids<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  chat_id: &str,\n) -> Result<Vec<String>, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let row = sqlx::query!(\n    r#\"\n        SELECT rag_ids\n        FROM af_chat\n        WHERE chat_id = $1 AND deleted_at IS NULL\n    \"#,\n    &chat_id,\n  )\n  .fetch_one(executor)\n  .await?;\n  let rag_ids = serde_json::from_value::<Vec<String>>(row.rag_ids).unwrap_or_default();\n  Ok(rag_ids)\n}\n\npub async fn insert_answer_message_with_transaction(\n  transaction: &mut Transaction<'_, Postgres>,\n  author: ChatAuthor,\n  chat_id: &str,\n  content: String,\n  metadata: serde_json::Value,\n  answer_message_id: i64,\n) -> Result<ChatMessage, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let existing_reply_id: Option<i64> = sqlx::query_scalar!(\n    r#\"\n      SELECT reply_message_id\n      FROM af_chat_messages\n      WHERE message_id = $1\n    \"#,\n    answer_message_id\n  )\n  .fetch_one(transaction.deref_mut())\n  .await?;\n\n  if let Some(reply_id) = existing_reply_id {\n    // Update the existing reply and RETURN the full row in one go\n    sqlx::query!(\n      r#\"\n         UPDATE af_chat_messages\n         SET content = $2,\n             author = $3,\n             created_at = CURRENT_TIMESTAMP,\n             meta_data = $4\n         WHERE message_id = $1\n      \"#,\n      reply_id,\n      &content,\n      json!(author),\n      metadata,\n    )\n    .execute(transaction.deref_mut())\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to update chat message: {}\", err)))?;\n\n    let row = sqlx::query!(\n      r#\"\n        SELECT message_id, content, created_at, author, meta_data, reply_message_id\n        FROM af_chat_messages\n        WHERE message_id = $1\n      \"#,\n      reply_id\n    )\n    .fetch_one(transaction.deref_mut())\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to fetch updated message: {}\", err)))?;\n\n    let chat_message = ChatMessage {\n      author,\n      message_id: row.message_id,\n      content: row.content,\n      created_at: row.created_at,\n      metadata: row.meta_data,\n      reply_message_id: Some(answer_message_id),\n    };\n\n    Ok(chat_message)\n  } else {\n    // Insert a new chat message\n    let row = sqlx::query!(\n      r#\"\n        INSERT INTO af_chat_messages (chat_id, author, content, meta_data)\n        VALUES ($1, $2, $3, $4)\n        RETURNING message_id, created_at\n      \"#,\n      chat_id,\n      json!(author),\n      &content,\n      &metadata,\n    )\n    .fetch_one(transaction.deref_mut())\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to insert chat message: {}\", err)))?;\n\n    // Update the question message with the new reply_message_id\n    sqlx::query!(\n      r#\"\n        UPDATE af_chat_messages\n        SET reply_message_id = $2\n        WHERE message_id = $1\n      \"#,\n      answer_message_id,\n      row.message_id,\n    )\n    .execute(transaction.deref_mut())\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to update reply_message_id: {}\", err)))?;\n\n    // For answer message, the reply_message_id will be None\n    let chat_message = ChatMessage {\n      author,\n      message_id: row.message_id,\n      content,\n      created_at: row.created_at,\n      metadata,\n      reply_message_id: None,\n    };\n\n    Ok(chat_message)\n  }\n}\n\npub async fn insert_answer_message(\n  pg_pool: &PgPool,\n  author: ChatAuthor,\n  chat_id: &str,\n  content: String,\n  metadata: Option<serde_json::Value>,\n  question_message_id: i64,\n) -> Result<ChatMessage, AppError> {\n  let mut txn = pg_pool.begin().await?;\n  let chat_message = insert_answer_message_with_transaction(\n    &mut txn,\n    author,\n    chat_id,\n    content,\n    metadata.unwrap_or_default(),\n    question_message_id,\n  )\n  .await?;\n  txn.commit().await.map_err(|err| {\n    AppError::Internal(anyhow!(\n      \"Failed to commit transaction to insert answer message: {}\",\n      err\n    ))\n  })?;\n  Ok(chat_message)\n}\n\npub async fn insert_question_message<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  author: ChatAuthorWithUuid,\n  chat_id: &str,\n  content: String,\n) -> Result<ChatMessageWithAuthorUuid, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let row = sqlx::query!(\n    r#\"\n        INSERT INTO af_chat_messages (chat_id, author, content)\n        VALUES ($1, $2, $3)\n        RETURNING message_id, created_at\n        \"#,\n    chat_id,\n    json!(author),\n    &content,\n  )\n  .fetch_one(executor)\n  .await\n  .map_err(|err| AppError::Internal(anyhow!(\"Failed to insert chat message: {}\", err)))?;\n\n  let chat_message = ChatMessageWithAuthorUuid {\n    author,\n    message_id: row.message_id,\n    content,\n    metadata: json!([]),\n    created_at: row.created_at,\n    reply_message_id: None,\n  };\n  Ok(chat_message)\n}\n\n// Deprecated since v0.9.24\npub async fn select_chat_messages(\n  txn: &mut Transaction<'_, Postgres>,\n  chat_id: &str,\n  params: GetChatMessageParams,\n) -> Result<RepeatedChatMessage, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let mut query = r#\"\n        SELECT message_id, content, created_at, author, meta_data, reply_message_id\n        FROM af_chat_messages\n        WHERE chat_id = $1\n    \"#\n  .to_string();\n\n  let mut args = PgArguments::default();\n  args\n    .add(&chat_id)\n    .map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode chat id {}\", chat_id),\n      err,\n    })?;\n\n  // Message IDs:   1    2    3    4    5\n  // AfterMessageId(3, 5):   [4]  [5]  has_more = false\n  // BeforeMessageId(3, 5):  [1]  [2]  has_more = false\n  // Offset(3, 5):           [4]  [5]  has_more = true\n  match params.cursor {\n    MessageCursor::AfterMessageId(after_message_id) => {\n      query += \" AND message_id > $2\";\n      args\n        .add(after_message_id)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode message id {}\", after_message_id),\n          err,\n        })?;\n      query += \" ORDER BY message_id DESC LIMIT $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n    MessageCursor::Offset(offset) => {\n      query += \" ORDER BY message_id ASC LIMIT $2 OFFSET $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n      args\n        .add(offset as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode offset {}\", offset as i64),\n          err,\n        })?;\n    },\n    MessageCursor::BeforeMessageId(before_message_id) => {\n      query += \" AND message_id < $2\";\n      args\n        .add(before_message_id)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode message id {}\", before_message_id),\n          err,\n        })?;\n      query += \" ORDER BY message_id DESC LIMIT $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n    MessageCursor::NextBack => {\n      query += \" ORDER BY message_id DESC LIMIT $2\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n  }\n\n  #[allow(clippy::type_complexity)]\n  let rows: Vec<(\n    i64,\n    String,\n    DateTime<Utc>,\n    serde_json::Value,\n    serde_json::Value,\n    Option<i64>,\n  )> = sqlx::query_as_with(&query, args)\n    .fetch_all(txn.deref_mut())\n    .await?;\n\n  let messages = rows\n    .into_iter()\n    .flat_map(\n      |(message_id, content, created_at, author, metadata, reply_message_id)| {\n        match serde_json::from_value::<ChatAuthor>(author) {\n          Ok(author) => Some(ChatMessage {\n            author,\n            message_id,\n            content,\n            created_at,\n            metadata,\n            reply_message_id,\n          }),\n          Err(err) => {\n            warn!(\"Failed to deserialize author: {}\", err);\n            None\n          },\n        }\n      },\n    )\n    .collect::<Vec<ChatMessage>>();\n\n  let total = sqlx::query_scalar!(\n    r#\"\n        SELECT COUNT(*)\n        FROM public.af_chat_messages\n        WHERE chat_id = $1\n        \"#,\n    &chat_id\n  )\n  .fetch_one(txn.deref_mut())\n  .await?\n  .unwrap_or(0);\n\n  let has_more = match params.cursor {\n    MessageCursor::AfterMessageId(_) => {\n      if messages.is_empty() {\n        false\n      } else {\n        sqlx::query!(\n          \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id > $2)\",\n          &chat_id,\n          messages[0].message_id\n        )\n        .fetch_one(txn.deref_mut())\n        .await?\n        .exists\n        .unwrap_or(false)\n      }\n    },\n    MessageCursor::Offset(offset) => (offset + params.limit) < total as u64,\n    MessageCursor::BeforeMessageId(_) => {\n      if messages.is_empty() {\n        false\n      } else {\n        sqlx::query!(\n          \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id < $2)\",\n          &chat_id,\n          messages.last().as_ref().unwrap().message_id\n        )\n        .fetch_one(txn.deref_mut())\n        .await?\n        .exists\n        .unwrap_or(false)\n      }\n    },\n    MessageCursor::NextBack => params.limit < total as u64,\n  };\n\n  Ok(RepeatedChatMessage {\n    messages,\n    total,\n    has_more,\n  })\n}\n\npub async fn select_chat_messages_with_author_uuid(\n  txn: &mut Transaction<'_, Postgres>,\n  chat_id: &str,\n  params: GetChatMessageParams,\n) -> Result<RepeatedChatMessageWithAuthorUuid, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let mut query = r#\"\n        SELECT\n          cm.message_id,\n          cm.content,\n          cm.created_at,\n          cm.author,\n          af_user.uuid AS author_uuid,\n          cm.meta_data,\n          cm.reply_message_id\n        FROM af_chat_messages AS cm\n        LEFT OUTER JOIN af_user ON (cm.author->>'author_id')::BIGINT = af_user.uid\n        WHERE chat_id = $1\n    \"#\n  .to_string();\n\n  let mut args = PgArguments::default();\n  args\n    .add(&chat_id)\n    .map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode chat id {}\", chat_id),\n      err,\n    })?;\n\n  // Message IDs:   1    2    3    4    5\n  // AfterMessageId(3, 5):   [4]  [5]  has_more = false\n  // BeforeMessageId(3, 5):  [1]  [2]  has_more = false\n  // Offset(3, 5):           [4]  [5]  has_more = true\n  match params.cursor {\n    MessageCursor::AfterMessageId(after_message_id) => {\n      query += \" AND message_id > $2\";\n      args\n        .add(after_message_id)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode message id {}\", after_message_id),\n          err,\n        })?;\n      query += \" ORDER BY message_id DESC LIMIT $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n    MessageCursor::Offset(offset) => {\n      query += \" ORDER BY message_id ASC LIMIT $2 OFFSET $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n      args\n        .add(offset as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode offset {}\", offset as i64),\n          err,\n        })?;\n    },\n    MessageCursor::BeforeMessageId(before_message_id) => {\n      query += \" AND message_id < $2\";\n      args\n        .add(before_message_id)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode message id {}\", before_message_id),\n          err,\n        })?;\n      query += \" ORDER BY message_id DESC LIMIT $3\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n    MessageCursor::NextBack => {\n      query += \" ORDER BY message_id DESC LIMIT $2\";\n      args\n        .add(params.limit as i64)\n        .map_err(|err| AppError::SqlxArgEncodingError {\n          desc: format!(\"unable to encode row limit {}\", params.limit as i64),\n          err,\n        })?;\n    },\n  }\n\n  #[allow(clippy::type_complexity)]\n  let rows: Vec<(\n    i64,\n    String,\n    DateTime<Utc>,\n    serde_json::Value,\n    Option<Uuid>,\n    serde_json::Value,\n    Option<i64>,\n  )> = sqlx::query_as_with(&query, args)\n    .fetch_all(txn.deref_mut())\n    .await?;\n\n  let messages = rows\n    .into_iter()\n    .flat_map(\n      |(message_id, content, created_at, author, author_uuid, metadata, reply_message_id)| {\n        match serde_json::from_value::<ChatAuthor>(author) {\n          Ok(author) => Some(ChatMessageWithAuthorUuid {\n            author: ChatAuthorWithUuid {\n              author_id: author.author_id,\n              author_type: author.author_type,\n              author_uuid: author_uuid.unwrap_or(Uuid::nil()),\n              meta: author.meta,\n            },\n            message_id,\n            content,\n            metadata,\n            created_at,\n            reply_message_id,\n          }),\n          Err(err) => {\n            warn!(\"Failed to deserialize author: {}\", err);\n            None\n          },\n        }\n      },\n    )\n    .collect::<Vec<ChatMessageWithAuthorUuid>>();\n\n  let total = sqlx::query_scalar!(\n    r#\"\n        SELECT COUNT(*)\n        FROM public.af_chat_messages\n        WHERE chat_id = $1\n        \"#,\n    &chat_id\n  )\n  .fetch_one(txn.deref_mut())\n  .await?\n  .unwrap_or(0);\n\n  let has_more = match params.cursor {\n    MessageCursor::AfterMessageId(_) => {\n      if messages.is_empty() {\n        false\n      } else {\n        sqlx::query!(\n          \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id > $2)\",\n          &chat_id,\n          messages[0].message_id\n        )\n        .fetch_one(txn.deref_mut())\n        .await?\n        .exists\n        .unwrap_or(false)\n      }\n    },\n    MessageCursor::Offset(offset) => (offset + params.limit) < total as u64,\n    MessageCursor::BeforeMessageId(_) => {\n      if messages.is_empty() {\n        false\n      } else {\n        sqlx::query!(\n          \"SELECT EXISTS(SELECT 1 FROM af_chat_messages WHERE chat_id = $1 AND message_id < $2)\",\n          &chat_id,\n          messages.last().as_ref().unwrap().message_id\n        )\n        .fetch_one(txn.deref_mut())\n        .await?\n        .exists\n        .unwrap_or(false)\n      }\n    },\n    MessageCursor::NextBack => params.limit < total as u64,\n  };\n\n  Ok(RepeatedChatMessageWithAuthorUuid {\n    messages,\n    total,\n    has_more,\n  })\n}\n\npub async fn get_all_chat_messages<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  chat_id: &str,\n) -> Result<Vec<ChatMessage>, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let rows = sqlx::query!(\n    // ChatMessage,\n    r#\"\n     SELECT message_id, content, created_at, author, meta_data, reply_message_id\n          FROM af_chat_messages\n          WHERE chat_id = $1\n          ORDER BY created_at ASC\n   \"#,\n    chat_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  let messages = rows\n    .into_iter()\n    .flat_map(\n      |row| match serde_json::from_value::<ChatAuthor>(row.author) {\n        Ok(author) => Some(ChatMessage {\n          author,\n          message_id: row.message_id,\n          content: row.content,\n          created_at: row.created_at,\n          metadata: row.meta_data,\n          reply_message_id: row.reply_message_id,\n        }),\n        Err(err) => {\n          warn!(\"Failed to deserialize author: {}\", err);\n          None\n        },\n      },\n    )\n    .collect::<Vec<ChatMessage>>();\n\n  Ok(messages)\n}\n\npub async fn delete_answer_message_by_question_message_id(\n  transaction: &mut Transaction<'_, Postgres>,\n  message_id: i64,\n) -> Result<(), AppError> {\n  // Step 1: Get the reply_message_id of the chat message with the given message_id\n  let reply_message_id: Option<i64> = sqlx::query_scalar!(\n    r#\"\n      SELECT reply_message_id\n      FROM af_chat_messages\n      WHERE message_id = $1\n    \"#,\n    message_id\n  )\n  .fetch_one(transaction.deref_mut())\n  .await?;\n\n  if let Some(reply_id) = reply_message_id {\n    // Step 2: Delete the chat message with the reply_message_id\n    sqlx::query!(\n      r#\"\n        DELETE FROM af_chat_messages\n        WHERE message_id = $1\n      \"#,\n      reply_id\n    )\n    .execute(transaction.deref_mut())\n    .await?;\n  }\n\n  Ok(())\n}\n\npub async fn update_chat_message_content(\n  transaction: &mut Transaction<'_, Postgres>,\n  params: &UpdateChatMessageContentParams,\n) -> Result<(), AppError> {\n  sqlx::query(\n    r#\"\n            UPDATE af_chat_messages\n            SET content = $2,\n                edited_at = CURRENT_TIMESTAMP\n            WHERE message_id = $1\n            \"#,\n  )\n  .bind(params.message_id)\n  .bind(&params.content)\n  .execute(transaction.deref_mut())\n  .await?;\n\n  Ok(())\n}\n\npub async fn update_chat_message_meta(\n  transaction: &mut Transaction<'_, Postgres>,\n  params: &UpdateChatMessageMetaParams,\n) -> Result<(), AppError> {\n  for (key, value) in params.meta_data.iter() {\n    sqlx::query(\n      r#\"\n            UPDATE af_chat_messages\n            SET meta_data = jsonb_set(\n                COALESCE(meta_data, '{}'),\n                $2,\n                $3::jsonb,\n                true\n            )\n            WHERE message_id = $1\n            \"#,\n    )\n    .bind(params.message_id)\n    .bind(format!(\"{{{}}}\", key))\n    .bind(value)\n    .execute(transaction.deref_mut())\n    .await?;\n  }\n\n  Ok(())\n}\n\npub async fn select_chat_message_content<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  message_id: i64,\n) -> Result<(String, serde_json::Value), AppError> {\n  let row = sqlx::query!(\n    r#\"\n        SELECT content,meta_data\n        FROM af_chat_messages\n        WHERE message_id = $1\n        \"#,\n    message_id,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok((row.content, row.meta_data))\n}\n\npub async fn select_chat_message_matching_reply_message_id(\n  txn: &mut Transaction<'_, Postgres>,\n  chat_id: &str,\n  reply_message_id: i64,\n) -> Result<Option<ChatMessage>, AppError> {\n  let chat_id = Uuid::from_str(chat_id)?;\n  let row = sqlx::query!(\n    r#\"\n        SELECT message_id, content, created_at, author, meta_data, reply_message_id\n        FROM af_chat_messages\n        WHERE chat_id = $1\n        AND reply_message_id = $2\n    \"#,\n    &chat_id,\n    reply_message_id\n  )\n  .fetch_one(txn.deref_mut())\n  .await?;\n\n  let message = match serde_json::from_value::<ChatAuthor>(row.author) {\n    Ok(author) => Some(ChatMessage {\n      author,\n      message_id: row.message_id,\n      content: row.content,\n      created_at: row.created_at,\n      metadata: row.meta_data,\n      reply_message_id: row.reply_message_id,\n    }),\n    Err(err) => {\n      warn!(\"Failed to deserialize author: {}\", err);\n      None\n    },\n  };\n\n  Ok(message)\n}\n"
  },
  {
    "path": "libs/database/src/chat/mod.rs",
    "content": "pub mod chat_ops;\n"
  },
  {
    "path": "libs/database/src/collab/collab_db_ops.rs",
    "content": "use anyhow::{anyhow, Context};\nuse collab_entity::CollabType;\nuse database_entity::dto::{\n  AFCollabEmbedInfo, AFSnapshotMeta, AFSnapshotMetas, CollabParams, QueryCollab, QueryCollabResult,\n  RawData, RepeatedAFCollabEmbedInfo,\n};\nuse shared_entity::dto::workspace_dto::{DatabaseRowUpdatedItem, EmbeddedCollabQuery};\n\nuse crate::collab::{partition_key_from_collab_type, SNAPSHOT_PER_HOUR};\nuse crate::pg_row::AFSnapshotRow;\nuse crate::pg_row::{AFCollabData, AFCollabRowMeta};\nuse app_error::AppError;\nuse chrono::{DateTime, Duration, Utc};\n\nuse sqlx::{Error, Executor, PgPool, Postgres, Transaction};\nuse std::collections::{HashMap, HashSet};\nuse std::fmt::Debug;\nuse std::ops::DerefMut;\nuse tracing::{error, instrument};\nuse uuid::Uuid;\n\n/// Inserts a new row into the `af_collab` table or updates an existing row if it matches the\n/// provided `object_id`.Additionally, if the row is being inserted for the first time, a corresponding\n/// entry will be added to the `af_collab_member` table.\n///\n/// # Arguments\n///\n/// * `tx` - A mutable reference to a PostgreSQL transaction.\n/// * `params` - Parameters required for the insertion or update operation, encapsulated in\n/// the `InsertCollabParams` struct.\n///\n/// # Returns\n///\n/// * `Ok(())` if the operation is successful.\n/// * `Err(StorageError::Internal)` if there's an attempt to insert a row with an existing `object_id` but a different `workspace_id`.\n/// * `Err(sqlx::Error)` for other database-related errors.\n///\n/// # Errors\n///\n/// This function will return an error if:\n/// * There's a database operation failure.\n/// * There's an attempt to insert a row with an existing `object_id` but a different `workspace_id`.\n///\n#[inline]\n#[instrument(level = \"trace\", skip(tx, params), fields(oid=%params.object_id), err)]\npub async fn insert_into_af_collab(\n  tx: &mut Transaction<'_, sqlx::Postgres>,\n  uid: &i64,\n  workspace_id: &Uuid,\n  params: &CollabParams,\n) -> Result<(), AppError> {\n  let partition_key = crate::collab::partition_key_from_collab_type(&params.collab_type);\n  tracing::trace!(\n    \"upsert collab:{}, len:{}, update_at:{:?}\",\n    params.object_id,\n    params.encoded_collab_v1.len(),\n    params.updated_at.map(|v| v.timestamp_millis())\n  );\n\n  sqlx::query!(\n    r#\"\n      INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\n      VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW())) ON CONFLICT (oid)\n      DO UPDATE SET blob = $2, len = $3, owner_uid = $5, updated_at = COALESCE($7, NOW()) WHERE excluded.workspace_id = af_collab.workspace_id;\n    \"#,\n    params.object_id,\n    params.encoded_collab_v1.as_ref(),\n    params.encoded_collab_v1.len() as i32,\n    partition_key,\n    uid,\n    workspace_id,\n    params.updated_at\n  )\n  .execute(tx.deref_mut())\n  .await.map_err(|err| {\n    AppError::Internal(anyhow!(\n      \"Update af_collab failed: workspace_id:{}, uid:{}, object_id:{}, collab_type:{}. error: {:?}\",\n      workspace_id, uid, params.object_id, params.collab_type, err,\n    ))\n  })?;\n\n  Ok(())\n}\n\n/// Inserts or updates multiple collaboration records for a specific user in bulk. It assumes you are the\n/// owner of the workspace.\n///\n/// This function performs a bulk insert or update operation for collaboration records (`af_collab`)\n/// and corresponding member records (`af_collab_member`) for a given user and workspace. It processes a\n/// list of collaboration parameters (`CollabParams`) and ensures that the data is inserted efficiently.\n///\n/// It will return error: ON CONFLICT DO UPDATE command cannot affect row a second time, when you're\n/// trying to insert duplicate rows with the same constrained values in a single INSERT statement.\n/// PostgreSQL’s ON CONFLICT DO UPDATE cannot handle multiple duplicate rows within the same batch.\n///\n/// # Concurrency and Locking:\n///\n/// - **Row-level locks**: PostgreSQL acquires row-level locks during inserts or updates, especially with\n///   the `ON CONFLICT` clause, which resolves conflicts by updating existing rows. If multiple transactions\n///   attempt to modify the same rows (with the same `oid` and `partition_key`), PostgreSQL will serialize\n///   access, allowing only one transaction to modify the rows at a time.\n/// - **No table-wide locks**: Other inserts or updates on different rows can proceed concurrently without\n///   locking the entire table.\n/// - **Deadlock risk**: Deadlocks may occur when transactions attempt to modify the same rows concurrently,\n///   but PostgreSQL automatically resolves them by aborting one of the transactions. To minimize this risk,\n///   ensure transactions access rows in a consistent order.\n///\n/// # Best Practices for High Concurrency:\n///\n/// - **Batch inserts**: To reduce row-level contention, consider breaking large datasets into smaller batches\n///   (e.g., 100 rows at a time) when performing bulk inserts.\n/// | Row Size | Total Rows | Batch Insert Time | Chunked Insert Time (2000-row chunks) |\n/// |----------|------------|-------------------|--------------------------------------|\n/// | 1KB      | 500        | 41.43 ms          | 31.24 ms                             |\n/// | 1KB      | 1000       | 79.30 ms          | 48.07 ms                             |\n/// | 1KB      | 2000       | 129.50 ms         | 86.75 ms                             |\n/// | 1KB      | 3000       | 153.59 ms         | 121.09 ms                            |\n/// | 1KB      | 6000       | 427.08 ms         | 500.08 ms                            |\n/// | 5KB      | 500        | 79.70 ms          | 66.98 ms                             |\n/// | 5KB      | 1000       | 140.58 ms         | 121.60 ms                            |\n/// | 5KB      | 2000       | 257.42 ms         | 245.02 ms                            |\n/// | 5KB      | 3000       | 418.10 ms         | 380.64 ms                            |\n/// | 5KB      | 6000       | 776.63 ms         | 730.69 ms                            |\n/// For 1KB rows: Chunked inserts provide better performance for small datasets (up to 3000 rows), but batch inserts become more efficient for larger datasets (6000+ rows).\n/// For 5KB rows: Chunked inserts consistently outperform or match batch inserts, making them the preferred method across different dataset sizes.\n///\n/// - **Consistent transaction ordering**: Access rows in a consistent order across transactions to reduce\n///   the risk of deadlocks.\n/// - **Optimistic concurrency control**: For highly concurrent environments, implement optimistic concurrency\n///   control to handle conflicts after they occur rather than preventing them upfront.\n///\n/// # Why Use a Transaction Instead of `PgPool`:\n///\n/// -  Using a transaction ensures that all database operations (insert/update for both\n///   `af_collab` and `af_collab_member`) succeed or fail together. This means that if any part of the\n///   operation fails, all changes will be rolled back, ensuring data consistency.\n///\n///\n#[inline]\n#[instrument(level = \"trace\", skip_all, fields(uid=%uid, workspace_id=%workspace_id), err)]\npub async fn insert_into_af_collab_bulk_for_user(\n  tx: &mut Transaction<'_, Postgres>,\n  uid: &i64,\n  workspace_id: Uuid,\n  collab_params_list: &[CollabParams],\n) -> Result<(), AppError> {\n  if collab_params_list.is_empty() {\n    return Ok(());\n  }\n\n  // Insert values into `af_collab` tables in bulk\n  let len = collab_params_list.len();\n  let mut object_ids = Vec::with_capacity(len);\n  let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(len);\n  let mut lengths: Vec<i32> = Vec::with_capacity(len);\n  let mut partition_keys: Vec<i32> = Vec::with_capacity(len);\n  let mut visited = HashSet::with_capacity(len);\n  let mut updated_at = Vec::with_capacity(len);\n  let now = Utc::now();\n  for params in collab_params_list.iter() {\n    let oid = params.object_id;\n    if visited.insert(oid) {\n      let partition_key = partition_key_from_collab_type(&params.collab_type);\n      object_ids.push(oid);\n      blobs.push(params.encoded_collab_v1.to_vec());\n      lengths.push(params.encoded_collab_v1.len() as i32);\n      partition_keys.push(partition_key);\n      updated_at.push(params.updated_at.unwrap_or(now));\n    }\n  }\n\n  let uids: Vec<i64> = vec![*uid; object_ids.len()];\n  let workspace_ids: Vec<Uuid> = vec![workspace_id; object_ids.len()];\n  // Bulk insert into `af_collab` for the provided collab params\n  sqlx::query!(\n      r#\"\n        INSERT INTO af_collab (oid, blob, len, partition_key, owner_uid, workspace_id, updated_at)\n        SELECT * FROM UNNEST($1::uuid[], $2::bytea[], $3::int[], $4::int[], $5::bigint[], $6::uuid[], $7::timestamp with time zone[])\n        ON CONFLICT (oid)\n        DO UPDATE SET blob = excluded.blob, len = excluded.len, updated_at = excluded.updated_at where af_collab.workspace_id = excluded.workspace_id\n      \"#,\n      &object_ids,\n      &blobs,\n      &lengths,\n      &partition_keys,\n      &uids,\n      &workspace_ids,\n      &updated_at\n    )\n      .execute(tx.deref_mut())\n      .await\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n            \"Bulk insert/update into af_collab failed for uid: {}, error details: {:?}\",\n            uid,\n            err\n        ))\n      })?;\n\n  Ok(())\n}\n\n#[inline]\npub async fn select_collabs_created_since<'a, E>(\n  conn: E,\n  workspace_id: &Uuid,\n  since: DateTime<Utc>,\n  limit: usize,\n) -> Result<Vec<AFCollabData>, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let records = sqlx::query_as!(\n    AFCollabData,\n    r#\"\n        SELECT c.oid, c.partition_key, c.updated_at, c.blob\n        FROM af_collab c\n        WHERE c.workspace_id = $1\n            AND c.deleted_at IS NULL\n            AND c.created_at > $2\n        ORDER BY updated_at\n        LIMIT $3\n        \"#,\n    workspace_id,\n    since,\n    limit as i64\n  )\n  .fetch_all(conn)\n  .await?;\n  Ok(records)\n}\n\npub async fn select_collab_id_exists<'a, E>(conn: E, object_id: &Uuid) -> Result<bool, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let exists = sqlx::query_scalar!(\n    r#\"\n        SELECT EXISTS (\n            SELECT 1 FROM af_collab\n            WHERE oid = $1 AND deleted_at IS NULL\n        )\n        \"#,\n    object_id,\n  )\n  .fetch_one(conn)\n  .await?;\n  Ok(exists.unwrap_or(false))\n}\n\n#[inline]\npub async fn select_blob_from_af_collab<'a, E>(\n  conn: E,\n  collab_type: &CollabType,\n  object_id: &Uuid,\n) -> Result<(DateTime<Utc>, Vec<u8>), sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let partition_key = partition_key_from_collab_type(collab_type);\n  let record = sqlx::query!(\n    r#\"\n        SELECT updated_at, blob\n        FROM af_collab\n        WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\n        \"#,\n    object_id,\n    partition_key,\n  )\n  .fetch_one(conn)\n  .await?;\n  Ok((record.updated_at, record.blob))\n}\n\n#[inline]\npub async fn select_collab_meta_from_af_collab<'a, E>(\n  conn: E,\n  object_id: &Uuid,\n  collab_type: &CollabType,\n) -> Result<Option<AFCollabRowMeta>, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let partition_key = partition_key_from_collab_type(collab_type);\n  sqlx::query_as!(\n    AFCollabRowMeta,\n    r#\"\n        SELECT oid,workspace_id,owner_uid,deleted_at,created_at,updated_at\n        FROM af_collab\n        WHERE oid = $1 AND partition_key = $2 AND deleted_at IS NULL;\n        \"#,\n    object_id,\n    partition_key,\n  )\n  .fetch_optional(conn)\n  .await\n}\n\n#[inline]\npub async fn batch_select_collab_blob(\n  pg_pool: &PgPool,\n  queries: Vec<QueryCollab>,\n  results: &mut HashMap<Uuid, QueryCollabResult>,\n) {\n  let mut object_ids_by_collab_type: HashMap<CollabType, Vec<Uuid>> = HashMap::new();\n  for params in queries {\n    object_ids_by_collab_type\n      .entry(params.collab_type)\n      .or_default()\n      .push(params.object_id);\n  }\n\n  for (_collab_type, mut object_ids) in object_ids_by_collab_type.into_iter() {\n    let par_results: Result<Vec<QueryCollabData>, sqlx::Error> = sqlx::query_as!(\n      QueryCollabData,\n      r#\"\n       SELECT oid, blob\n       FROM af_collab\n       WHERE oid = ANY($1) AND deleted_at IS NULL;\n    \"#,\n      &object_ids\n    )\n    .fetch_all(pg_pool)\n    .await;\n\n    match par_results {\n      Ok(par_results) => {\n        object_ids.retain(|oid| !par_results.iter().any(|par_result| par_result.oid == *oid));\n\n        results.extend(par_results.into_iter().map(|par_result| {\n          (\n            par_result.oid,\n            QueryCollabResult::Success {\n              encode_collab_v1: par_result.blob,\n            },\n          )\n        }));\n\n        results.extend(object_ids.into_iter().map(|oid| {\n          (\n            oid,\n            QueryCollabResult::Failed {\n              error: \"Record not found\".to_string(),\n            },\n          )\n        }));\n      },\n      Err(err) => error!(\"Batch get collab errors: {}\", err),\n    }\n  }\n}\n\n#[derive(Debug, sqlx::FromRow)]\nstruct QueryCollabData {\n  oid: Uuid,\n  blob: RawData,\n}\n\npub async fn create_snapshot(\n  pg_pool: &PgPool,\n  object_id: &str,\n  encoded_collab_v1: &[u8],\n  workspace_id: &Uuid,\n) -> Result<(), sqlx::Error> {\n  let encrypt = 0;\n\n  sqlx::query!(\n    r#\"\n        INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\n        VALUES ($1, $2, $3, $4, $5)\n        \"#,\n    object_id,\n    encoded_collab_v1,\n    encoded_collab_v1.len() as i32,\n    encrypt,\n    workspace_id,\n  )\n  .execute(pg_pool)\n  .await?;\n  Ok(())\n}\n\n/// Determines whether a new snapshot should be created for the given `oid`.\n///\n/// This asynchronous function checks the most recent snapshot creation time for the specified `oid`.\n/// It compares the creation time of the latest snapshot with the current time to decide whether a new\n/// snapshot should be created, based on a predefined interval (SNAPSHOT_PER_HOUR).\n///\n#[inline]\npub async fn latest_snapshot_time<'a, E: Executor<'a, Database = Postgres>>(\n  oid: &Uuid,\n  executor: E,\n) -> Result<Option<chrono::DateTime<Utc>>, sqlx::Error> {\n  let latest_snapshot_time: Option<chrono::DateTime<Utc>> = sqlx::query_scalar(\n    \"SELECT created_at FROM af_collab_snapshot\n         WHERE oid = $1 ORDER BY created_at DESC LIMIT 1\",\n  )\n  .bind(oid)\n  .fetch_optional(executor)\n  .await?;\n  Ok(latest_snapshot_time)\n}\n#[inline]\npub async fn should_create_snapshot2<'a, E: Executor<'a, Database = Postgres>>(\n  oid: &Uuid,\n  executor: E,\n) -> Result<bool, sqlx::Error> {\n  let hours = Utc::now() - Duration::hours(SNAPSHOT_PER_HOUR);\n  let latest_snapshot_time: Option<chrono::DateTime<Utc>> = sqlx::query_scalar(\n    \"SELECT created_at FROM af_collab_snapshot\n         WHERE oid = $1 ORDER BY created_at DESC LIMIT 1\",\n  )\n  .bind(oid)\n  .fetch_optional(executor)\n  .await?;\n  Ok(latest_snapshot_time.map(|t| t < hours).unwrap_or(true))\n}\n\n/// Creates a new snapshot in the `af_collab_snapshot` table and maintains the total number of snapshots\n/// within a specified limit for a given object ID (`oid`).\n///\n/// This asynchronous function inserts a new snapshot into the database and ensures that the total number\n/// of snapshots stored for the specified `oid` does not exceed the provided `snapshot_limit`. If the limit\n/// is exceeded, the oldest snapshots are deleted to maintain the limit.\n///\npub async fn create_snapshot_and_maintain_limit(\n  mut transaction: Transaction<'_, Postgres>,\n  workspace_id: &Uuid,\n  oid: &Uuid,\n  encoded_collab_v1: &[u8],\n  snapshot_limit: i64,\n) -> Result<AFSnapshotMeta, AppError> {\n  let snapshot_meta = sqlx::query_as!(\n    AFSnapshotMeta,\n    r#\"\n      INSERT INTO af_collab_snapshot (oid, blob, len, encrypt, workspace_id)\n      VALUES ($1, $2, $3, $4, $5)\n      RETURNING sid AS snapshot_id, oid AS object_id, created_at\n    \"#,\n    oid.to_string(),\n    encoded_collab_v1,\n    encoded_collab_v1.len() as i64,\n    0,\n    workspace_id,\n  )\n  .fetch_one(transaction.deref_mut())\n  .await?;\n\n  // When a new snapshot is created that surpasses the preset limit, older snapshots will be deleted to maintain the limit\n  sqlx::query(\n    r#\"\n       DELETE FROM af_collab_snapshot\n       WHERE oid = $1 AND sid NOT IN ( SELECT sid FROM af_collab_snapshot WHERE oid = $1 ORDER BY created_at DESC LIMIT $2)\n      \"#,\n    )\n    .bind(oid)\n    .bind(snapshot_limit)\n    .execute(transaction.deref_mut())\n    .await?;\n\n  transaction\n    .commit()\n    .await\n    .context(\"fail to commit the transaction to insert collab snapshot\")?;\n\n  Ok(snapshot_meta)\n}\n\n#[inline]\npub async fn select_snapshot(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  object_id: &Uuid,\n  snapshot_id: &i64,\n) -> Result<Option<AFSnapshotRow>, Error> {\n  let row = sqlx::query_as!(\n    AFSnapshotRow,\n    r#\"\n      SELECT * FROM af_collab_snapshot\n      WHERE sid = $1 AND oid = $2 AND workspace_id = $3 AND deleted_at IS NULL;\n    \"#,\n    snapshot_id,\n    object_id.to_string(),\n    workspace_id\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n  Ok(row)\n}\n\n#[inline]\npub async fn select_latest_snapshot(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  object_id: &str,\n) -> Result<Option<AFSnapshotRow>, Error> {\n  let row = sqlx::query_as!(\n    AFSnapshotRow,\n    r#\"\n      SELECT * FROM af_collab_snapshot\n      WHERE workspace_id = $1 AND oid = $2 AND deleted_at IS NULL\n      ORDER BY created_at DESC\n      LIMIT 1;\n    \"#,\n    workspace_id,\n    object_id\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n  Ok(row)\n}\n\n/// Returns list of snapshots for given object_id in descending order of creation time.\npub async fn get_all_collab_snapshot_meta(\n  pg_pool: &PgPool,\n  object_id: &Uuid,\n) -> Result<AFSnapshotMetas, Error> {\n  let snapshots: Vec<AFSnapshotMeta> = sqlx::query_as!(\n    AFSnapshotMeta,\n    r#\"\n    SELECT sid as \"snapshot_id\", oid as \"object_id\", created_at\n    FROM af_collab_snapshot\n    WHERE oid = $1 AND deleted_at IS NULL\n    ORDER BY created_at DESC;\n    \"#,\n    object_id.to_string()\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(AFSnapshotMetas(snapshots))\n}\n\n#[inline]\nfn transform_record_not_found_error(\n  result: Result<Option<bool>, sqlx::Error>,\n) -> Result<bool, sqlx::Error> {\n  match result {\n    Ok(value) => Ok(value.unwrap_or(false)),\n    Err(err) => {\n      if let Error::RowNotFound = err {\n        Ok(false)\n      } else {\n        Err(err)\n      }\n    },\n  }\n}\n\n/// Checks for the existence of a collaboration entry in the `af_collab` table using a specified `oid`.\n/// Use this method to verify if a specific collaboration object is already registered in the database.\n/// For a more efficient lookup, especially in frequent checks, consider using the cached method [CollabCache::is_exist].\n#[inline]\npub async fn is_collab_exists<'a, E: Executor<'a, Database = Postgres>>(\n  oid: &Uuid,\n  executor: E,\n) -> Result<bool, sqlx::Error> {\n  let result = sqlx::query_scalar!(\n    r#\"\n      SELECT EXISTS (SELECT 1 FROM af_collab WHERE oid = $1 LIMIT 1)\n    \"#,\n    &oid,\n  )\n  .fetch_one(executor)\n  .await;\n  transform_record_not_found_error(result)\n}\n\npub async fn select_workspace_database_oid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Uuid, sqlx::Error> {\n  let partition_key = partition_key_from_collab_type(&CollabType::WorkspaceDatabase);\n  sqlx::query_scalar!(\n    r#\"\n      SELECT oid\n      FROM af_collab\n      WHERE workspace_id = $1\n        AND partition_key = $2\n    \"#,\n    &workspace_id,\n    &partition_key,\n  )\n  .fetch_one(executor)\n  .await\n}\n\npub async fn select_last_updated_database_row_ids(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  row_ids: &[Uuid],\n  after: &DateTime<Utc>,\n) -> Result<Vec<DatabaseRowUpdatedItem>, sqlx::Error> {\n  let updated_row_items = sqlx::query_as!(\n    DatabaseRowUpdatedItem,\n    r#\"\n      SELECT\n        updated_at as updated_at,\n        oid as row_id\n      FROM af_collab\n      WHERE workspace_id = $1\n        AND oid = ANY($2)\n        AND updated_at > $3\n    \"#,\n    workspace_id,\n    row_ids,\n    after,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(updated_row_items)\n}\n\npub async fn select_collab_embed_info<'a, E>(\n  tx: E,\n  object_id: &Uuid,\n) -> Result<Option<AFCollabEmbedInfo>, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  tracing::info!(\"select_collab_embed_info: object_id: {}\", object_id);\n  let record = sqlx::query!(\n    r#\"\n      SELECT\n          ac.oid as object_id,\n          ace.partition_key,\n          ac.indexed_at,\n          ace.updated_at\n      FROM af_collab_embeddings ac\n      JOIN af_collab ace ON ac.oid = ace.oid\n      WHERE ac.oid = $1\n    \"#,\n    object_id\n  )\n  .fetch_optional(tx)\n  .await?;\n\n  let result = record.map(|row| AFCollabEmbedInfo {\n    object_id: row.object_id,\n    indexed_at: DateTime::<Utc>::from_naive_utc_and_offset(row.indexed_at, Utc),\n    updated_at: row.updated_at,\n  });\n\n  Ok(result)\n}\n\npub async fn batch_select_collab_embed<'a, E>(\n  executor: E,\n  embedded_collab: Vec<EmbeddedCollabQuery>,\n) -> Result<RepeatedAFCollabEmbedInfo, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let object_ids: Vec<_> = embedded_collab\n    .into_iter()\n    .map(|query| query.object_id)\n    .collect();\n\n  // Execute the query to fetch all matching rows\n  let records = sqlx::query!(\n    r#\"\n      SELECT\n          ac.oid as object_id,\n          ace.partition_key,\n          ac.indexed_at,\n          ace.updated_at\n      FROM af_collab_embeddings ac\n      JOIN af_collab ace ON ac.oid = ace.oid\n      WHERE ac.oid = ANY($1)\n    \"#,\n    &object_ids\n  )\n  .fetch_all(executor)\n  .await?;\n\n  // Organize the results by object_id\n  let mut items = vec![];\n  for row in records {\n    let embed_info = AFCollabEmbedInfo {\n      object_id: row.object_id,\n      indexed_at: DateTime::<Utc>::from_naive_utc_and_offset(row.indexed_at, Utc),\n      updated_at: row.updated_at,\n    };\n    items.push(embed_info);\n  }\n  Ok(RepeatedAFCollabEmbedInfo(items))\n}\n"
  },
  {
    "path": "libs/database/src/collab/collab_storage.rs",
    "content": "use app_error::AppError;\nuse async_trait::async_trait;\n\nuse database_entity::dto::{CollabParams, QueryCollab, QueryCollabResult};\n\nuse appflowy_proto::TimestampedEncodedCollab;\nuse collab_entity::CollabType;\nuse serde::{Deserialize, Serialize};\nuse sqlx::Transaction;\nuse std::collections::HashMap;\nuse uuid::Uuid;\n\npub const COLLAB_SNAPSHOT_LIMIT: i64 = 30;\npub const SNAPSHOT_PER_HOUR: i64 = 6;\npub type AppResult<T, E = AppError> = core::result::Result<T, E>;\n\n#[derive(Clone)]\npub enum GetCollabOrigin {\n  User { uid: i64 },\n  Server,\n}\n\nimpl From<i64> for GetCollabOrigin {\n  fn from(uid: i64) -> Self {\n    GetCollabOrigin::User { uid }\n  }\n}\n\n/// Represents a storage mechanism for collaborations.\n///\n/// This trait provides asynchronous methods for CRUD operations related to collaborations.\n/// Implementors of this trait should provide the actual storage logic, be it in-memory, file-based, database-backed, etc.\n#[async_trait]\npub trait CollabStore: Send + Sync + 'static {\n  async fn upsert_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> AppResult<()>;\n\n  async fn upsert_collab_background(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> AppResult<()>;\n\n  /// Insert a new collaboration in the storage.\n  ///\n  /// # Arguments\n  ///\n  /// * `params` - The parameters required to create a new collaboration.\n  ///\n  /// # Returns\n  ///\n  /// * `Result<()>` - Returns `Ok(())` if the collaboration was created successfully, `Err` otherwise.\n  async fn upsert_new_collab_with_transaction(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n    transaction: &mut Transaction<'_, sqlx::Postgres>,\n    action_description: &str,\n  ) -> AppResult<()>;\n\n  async fn batch_insert_new_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: Vec<CollabParams>,\n  ) -> AppResult<()>;\n\n  /// Retrieves a collaboration from the storage.\n  ///\n  /// # Arguments\n  ///\n  /// * `params` - The parameters required to query a collab object.\n  ///\n  /// # Returns\n  ///\n  /// * `Result<RawData>` - Returns the data of the collaboration if found, `Err` otherwise.\n  async fn get_full_encode_collab(\n    &self,\n    origin: GetCollabOrigin,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    collab_type: CollabType,\n  ) -> AppResult<TimestampedEncodedCollab>;\n\n  async fn batch_get_collab(\n    &self,\n    uid: &i64,\n    workspace_id: Uuid,\n    queries: Vec<QueryCollab>,\n  ) -> HashMap<Uuid, QueryCollabResult>;\n\n  /// Deletes a collaboration from the storage.\n  ///\n  /// # Arguments\n  ///\n  /// * `object_id` - A string slice that holds the ID of the collaboration to delete.\n  ///\n  /// # Returns\n  ///\n  /// * `Result<()>` - Returns `Ok(())` if the collaboration was deleted successfully, `Err` otherwise.\n  async fn delete_collab(&self, workspace_id: &Uuid, uid: &i64, object_id: &Uuid) -> AppResult<()>;\n\n  fn mark_as_editing(&self, oid: Uuid);\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CollabMetadata {\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n}\n\nmod uuid_str {\n  use serde::Deserialize;\n  use uuid::Uuid;\n\n  pub fn serialize<S>(uuid: &Uuid, serializer: S) -> Result<S::Ok, S::Error>\n  where\n    S: serde::Serializer,\n  {\n    serializer.serialize_str(&uuid.to_string())\n  }\n\n  pub fn deserialize<'de, D>(deserializer: D) -> Result<Uuid, D::Error>\n  where\n    D: serde::Deserializer<'de>,\n  {\n    let s = String::deserialize(deserializer)?;\n    Uuid::parse_str(&s).map_err(serde::de::Error::custom)\n  }\n}\n"
  },
  {
    "path": "libs/database/src/collab/mod.rs",
    "content": "mod collab_db_ops;\nmod collab_storage;\n\npub use collab_db_ops::*;\nuse collab_entity::CollabType;\npub use collab_storage::*;\n\npub(crate) fn partition_key_from_collab_type(collab_type: &CollabType) -> i32 {\n  match collab_type {\n    CollabType::Document => 0,\n    CollabType::Database => 1,\n    CollabType::WorkspaceDatabase => 2,\n    CollabType::Folder => 3,\n    CollabType::DatabaseRow => 4,\n    CollabType::UserAwareness => 5,\n    // TODO(nathan): create a partition table for CollabType::Unknown\n    CollabType::Unknown => 0,\n  }\n}\n"
  },
  {
    "path": "libs/database/src/file/file_storage.rs",
    "content": "use crate::pg_row::AFBlobMetadataRow;\nuse crate::resource_usage::{\n  delete_blob_metadata, get_blob_metadata, insert_blob_metadata, is_blob_metadata_exists,\n};\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse aws_sdk_s3::primitives::ByteStream;\nuse database_entity::file_dto::{\n  CompleteUploadRequest, CreateUploadRequest, CreateUploadResponse, UploadPartData,\n  UploadPartResponse,\n};\nuse sqlx::PgPool;\n\nuse tracing::{info, instrument, warn};\nuse uuid::Uuid;\n\npub trait ResponseBlob {\n  fn to_blob(self) -> Vec<u8>;\n  fn content_type(&self) -> Option<String>;\n}\n\n#[async_trait]\npub trait BucketClient {\n  type ResponseData: ResponseBlob;\n\n  async fn put_blob(\n    &self,\n    object_key: &str,\n    content: ByteStream,\n    content_type: Option<&str>,\n  ) -> Result<(), AppError>;\n\n  async fn put_blob_with_content_type(\n    &self,\n    object_key: &str,\n    stream: ByteStream,\n    content_type: &str,\n  ) -> Result<(), AppError>;\n\n  async fn delete_blob(&self, object_key: &str) -> Result<Self::ResponseData, AppError>;\n\n  async fn delete_blobs(&self, object_key: Vec<String>) -> Result<(), AppError>;\n\n  async fn get_blob(&self, object_key: &str) -> Result<Self::ResponseData, AppError>;\n\n  async fn create_upload(\n    &self,\n    object_key: &str,\n    req: CreateUploadRequest,\n  ) -> Result<CreateUploadResponse, AppError>;\n  async fn upload_part(\n    &self,\n    object_key: &str,\n    req: UploadPartData,\n  ) -> Result<UploadPartResponse, AppError>;\n  async fn complete_upload(\n    &self,\n    object_key: &str,\n    req: CompleteUploadRequest,\n  ) -> Result<(usize, String), AppError>;\n\n  async fn remove_dir(&self, dir: &str) -> Result<(), AppError>;\n\n  async fn list_dir(&self, dir: &str, limit: usize) -> Result<Vec<String>, AppError>;\n}\n\npub trait BlobKey: Send + Sync {\n  fn workspace_id(&self) -> &Uuid;\n  fn object_key(&self) -> String;\n  fn blob_metadata_key(&self) -> String;\n  fn e_tag(&self) -> &str;\n}\n\npub struct BucketStorage<C> {\n  client: C,\n  pg_pool: PgPool,\n}\n\nimpl<C> BucketStorage<C>\nwhere\n  C: BucketClient,\n{\n  pub fn new(client: C, pg_pool: PgPool) -> Self {\n    Self { client, pg_pool }\n  }\n\n  pub async fn remove_dir(&self, dir: &str) -> Result<(), AppError> {\n    info!(\"removing dir: {}\", dir);\n    self.client.remove_dir(dir).await?;\n    Ok(())\n  }\n\n  #[instrument(skip_all, err)]\n  #[inline]\n  pub async fn put_blob_with_content_type<K: BlobKey>(\n    &self,\n    key: K,\n    file_stream: ByteStream,\n    file_type: String,\n    file_size: usize,\n  ) -> Result<(), AppError> {\n    if is_blob_metadata_exists(&self.pg_pool, key.workspace_id(), &key.blob_metadata_key()).await? {\n      warn!(\n        \"file already exists, workspace_id: {}, blob_metadata_key: {}\",\n        key.workspace_id(),\n        key.blob_metadata_key()\n      );\n      return Ok(());\n    }\n\n    self\n      .client\n      .put_blob(&key.object_key(), file_stream, Some(&file_type))\n      .await?;\n    insert_blob_metadata(\n      &self.pg_pool,\n      &key.blob_metadata_key(),\n      key.workspace_id(),\n      &file_type,\n      file_size,\n    )\n    .await?;\n    Ok(())\n  }\n\n  pub async fn delete_blob(&self, key: impl BlobKey) -> Result<(), AppError> {\n    self.client.delete_blob(&key.object_key()).await?;\n\n    let mut tx = self.pg_pool.begin().await?;\n    delete_blob_metadata(&mut tx, key.workspace_id(), &key.blob_metadata_key()).await?;\n    tx.commit().await?;\n    Ok(())\n  }\n\n  pub async fn get_blob_metadata(\n    &self,\n    workspace_id: &Uuid,\n    metadata_key: &str,\n  ) -> Result<AFBlobMetadataRow, AppError> {\n    let metadata = get_blob_metadata(&self.pg_pool, workspace_id, metadata_key).await?;\n    Ok(metadata)\n  }\n\n  pub async fn get_blob(&self, key: &impl BlobKey) -> Result<Vec<u8>, AppError> {\n    let blob = self.client.get_blob(&key.object_key()).await?.to_blob();\n    Ok(blob)\n  }\n\n  pub async fn create_upload(\n    &self,\n    key: impl BlobKey,\n    req: CreateUploadRequest,\n  ) -> Result<CreateUploadResponse, AppError> {\n    self.client.create_upload(&key.object_key(), req).await\n  }\n\n  pub async fn upload_part(\n    &self,\n    key: impl BlobKey,\n    req: UploadPartData,\n  ) -> Result<UploadPartResponse, AppError> {\n    self.client.upload_part(&key.object_key(), req).await\n  }\n\n  pub async fn complete_upload(\n    &self,\n    key: impl BlobKey,\n    req: CompleteUploadRequest,\n  ) -> Result<(), AppError> {\n    if is_blob_metadata_exists(&self.pg_pool, key.workspace_id(), &key.object_key()).await? {\n      warn!(\n        \"file already exists, workspace_id: {}, request: {}\",\n        key.workspace_id(),\n        req\n      );\n      return Ok(());\n    }\n\n    let (content_length, content_type) =\n      self.client.complete_upload(&key.object_key(), req).await?;\n    insert_blob_metadata(\n      &self.pg_pool,\n      &key.blob_metadata_key(),\n      key.workspace_id(),\n      &content_type,\n      content_length,\n    )\n    .await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "libs/database/src/file/mod.rs",
    "content": "mod file_storage;\npub mod s3_client_impl;\nmod utils;\n\npub use file_storage::*;\n"
  },
  {
    "path": "libs/database/src/file/s3_client_impl.rs",
    "content": "use crate::file::{BucketClient, BucketStorage, ResponseBlob};\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse aws_sdk_s3::operation::delete_object::DeleteObjectOutput;\n\nuse std::ops::Deref;\nuse std::time::{Duration, SystemTime};\n\nuse aws_sdk_s3::error::SdkError;\nuse aws_sdk_s3::operation::delete_objects::DeleteObjectsOutput;\nuse aws_sdk_s3::operation::get_object::GetObjectError;\n\nuse aws_sdk_s3::presigning::PresigningConfig;\nuse aws_sdk_s3::primitives::ByteStream;\nuse aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, Delete, ObjectIdentifier};\nuse aws_sdk_s3::Client;\nuse database_entity::file_dto::{\n  CompleteUploadRequest, CreateUploadRequest, CreateUploadResponse, UploadPartData,\n  UploadPartResponse,\n};\n\nuse tracing::{debug, error, trace};\n\npub type S3BucketStorage = BucketStorage<AwsS3BucketClientImpl>;\n\nimpl S3BucketStorage {\n  pub fn from_bucket_impl(client: AwsS3BucketClientImpl, pg_pool: sqlx::PgPool) -> Self {\n    Self::new(client, pg_pool)\n  }\n}\n\n#[derive(Clone)]\npub struct AwsS3BucketClientImpl {\n  client: Client,\n  bucket: String,\n  endpoint: String,\n  presigned_url_endpoint: Option<String>,\n}\n\nimpl AwsS3BucketClientImpl {\n  pub fn new(\n    client: Client,\n    bucket: String,\n    endpoint: String,\n    presigned_url_endpoint: Option<String>,\n  ) -> Self {\n    debug_assert!(!bucket.is_empty());\n    AwsS3BucketClientImpl {\n      client,\n      bucket,\n      endpoint,\n      presigned_url_endpoint,\n    }\n  }\n\n  pub async fn gen_presigned_url(\n    &self,\n    s3_key: &str,\n    content_length: u64,\n    expires_in_secs: u64,\n  ) -> Result<String, AppError> {\n    let expires_in = Duration::from_secs(expires_in_secs);\n    let config = PresigningConfig::builder()\n      .start_time(SystemTime::now())\n      .expires_in(expires_in)\n      .build()\n      .map_err(|e| AppError::S3ResponseError(e.to_string()))?;\n\n    // There is no easy way to restrict file size of the upload (default limit max 5GB using PUT or other upload methods)\n    // https://github.com/aws/aws-sdk-net/issues/424\n    //\n    // consider using POST:\n    // https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html\n    let put_object_req = self\n      .client\n      .put_object()\n      .bucket(&self.bucket)\n      .key(s3_key)\n      .content_type(\"application/zip\")\n      .content_length(content_length as i64)\n      .presigned(config)\n      .await\n      .map_err(|err| AppError::Internal(anyhow!(\"Generate presigned url failed: {:?}\", err)))?;\n    let url = put_object_req.uri().to_string();\n\n    let public_url = self\n      .presigned_url_endpoint\n      .as_ref()\n      .map_or(url.clone(), |presigned| {\n        url.replace(&self.endpoint, presigned)\n      });\n    trace!(\n      \"generated presigned url: {}, public presigned url:{}, endpoint:{}, presigned_url_endpoint:{:?}\",\n      url,\n      public_url,\n      self.endpoint,\n      self.presigned_url_endpoint\n    );\n    Ok(public_url)\n  }\n\n  async fn complete_upload_and_get_metadata(\n    &self,\n    object_key: &str,\n    upload_id: &str,\n    completed_multipart_upload: CompletedMultipartUpload,\n  ) -> Result<(usize, String), AppError> {\n    // Complete the multipart upload\n    let _ = self\n      .client\n      .complete_multipart_upload()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .upload_id(upload_id)\n      .multipart_upload(completed_multipart_upload)\n      .send()\n      .await\n      .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;\n\n    // Retrieve the object metadata using head_object\n    let head_object_result = self\n      .client\n      .head_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n      .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;\n\n    let content_len = head_object_result\n      .content_length()\n      .ok_or_else(|| AppError::Unhandled(\"Content-Length not found\".to_string()))?;\n    let content_type = head_object_result\n      .content_type()\n      .map(|s| s.to_string())\n      .unwrap_or_else(|| \"application/octet-stream\".to_string());\n\n    trace!(\n      \"completed upload to S3: {} ({} bytes)\",\n      object_key,\n      content_len\n    );\n\n    Ok((content_len as usize, content_type))\n  }\n}\n\n#[async_trait]\nimpl BucketClient for AwsS3BucketClientImpl {\n  type ResponseData = S3ResponseData;\n\n  async fn put_blob(\n    &self,\n    object_key: &str,\n    content: ByteStream,\n    content_type: Option<&str>,\n  ) -> Result<(), AppError> {\n    self\n      .client\n      .put_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .body(content)\n      .content_type(content_type.unwrap_or(\"application/octet-stream\"))\n      .send()\n      .await\n      .map_err(|err| match err {\n        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) | SdkError::ServiceError(_) => {\n          AppError::ServiceTemporaryUnavailable(format!(\"Failed to upload object to S3: {}\", err))\n        },\n        _ => AppError::Internal(anyhow!(\"Failed to upload object to S3: {}\", err)),\n      })?;\n\n    trace!(\"put object to S3: {}\", object_key);\n\n    Ok(())\n  }\n\n  async fn put_blob_with_content_type(\n    &self,\n    object_key: &str,\n    stream: ByteStream,\n    content_type: &str,\n  ) -> Result<(), AppError> {\n    self\n      .client\n      .put_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .body(stream)\n      .content_type(content_type)\n      .send()\n      .await\n      .map_err(|err| match err {\n        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) | SdkError::ServiceError(_) => {\n          AppError::ServiceTemporaryUnavailable(format!(\"Failed to upload object to S3: {}\", err))\n        },\n        _ => AppError::Internal(anyhow!(\"Failed to upload object to S3: {}\", err)),\n      })?;\n\n    trace!(\"put object to S3: {} ({})\", object_key, content_type);\n\n    Ok(())\n  }\n\n  async fn delete_blob(&self, object_key: &str) -> Result<Self::ResponseData, AppError> {\n    let output = self\n      .client\n      .delete_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n      .map_err(|err| anyhow!(\"Failed to delete object to S3: {}\", err))?;\n\n    trace!(\"deleted object from S3: {}\", object_key);\n\n    Ok(S3ResponseData::from(output))\n  }\n\n  async fn delete_blobs(&self, object_keys: Vec<String>) -> Result<(), AppError> {\n    const CHUNK_SIZE: usize = 500;\n    let mut deleted = 0;\n    for chunk in object_keys.chunks(CHUNK_SIZE) {\n      let mut delete_object_ids = Vec::with_capacity(CHUNK_SIZE);\n      for obj in chunk {\n        let obj_id = ObjectIdentifier::builder()\n          .key(obj)\n          .build()\n          .map_err(|err| {\n            AppError::Internal(anyhow!(\"Failed to create object identifier: {}\", err))\n          })?;\n        delete_object_ids.push(obj_id);\n      }\n      let len = delete_object_ids.len();\n      let res = self\n        .client\n        .delete_objects()\n        .bucket(&self.bucket)\n        .delete(\n          Delete::builder()\n            .set_objects(Some(delete_object_ids))\n            .build()\n            .map_err(|err| {\n              AppError::Internal(anyhow!(\"Failed to create delete object request: {}\", err))\n            })?,\n        )\n        .send()\n        .await;\n\n      match res {\n        Ok(_) => deleted += len,\n        Err(err) => {\n          tracing::warn!(\"failed to delete {} objects: {}\", len, err);\n          tokio::time::sleep(Duration::from_millis(100)).await;\n        },\n      }\n    }\n\n    trace!(\"deleted {} objects from S3\", deleted);\n    Ok(())\n  }\n\n  async fn get_blob(&self, object_key: &str) -> Result<Self::ResponseData, AppError> {\n    match self\n      .client\n      .get_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n    {\n      Ok(output) => match output.body.collect().await {\n        Ok(body) => {\n          let data = body.into_bytes().to_vec();\n\n          trace!(\"get object from S3: {} ({} bytes)\", object_key, data.len());\n\n          Ok(S3ResponseData::new_with_data(data, output.content_type))\n        },\n        Err(err) => Err(AppError::from(anyhow!(\"Failed to collect body: {}\", err))),\n      },\n      Err(SdkError::ServiceError(service_err)) => match service_err.err() {\n        GetObjectError::NoSuchKey(_) => Err(AppError::RecordNotFound(format!(\n          \"blob not found for key:{object_key}\"\n        ))),\n        _ => Err(AppError::from(anyhow!(\n          \"Failed to get object from S3: {:?}\",\n          service_err\n        ))),\n      },\n      Err(err) => Err(AppError::from(anyhow!(\n        \"Failed to get object from S3: {}\",\n        err\n      ))),\n    }\n  }\n\n  /// Create a new upload session\n  /// https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html\n  async fn create_upload(\n    &self,\n    object_key: &str,\n    req: CreateUploadRequest,\n  ) -> Result<CreateUploadResponse, AppError> {\n    trace!(\"creating multi-part upload to S3: {} - {}\", object_key, req);\n\n    let multipart_upload_res = self\n      .client\n      .create_multipart_upload()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .content_type(req.content_type)\n      .send()\n      .await\n      .map_err(|err| anyhow!(format!(\"Failed to create upload: {:?}\", err)))?;\n\n    match multipart_upload_res.upload_id {\n      None => Err(anyhow!(\"Failed to create upload: upload_id is None\").into()),\n      Some(upload_id) => Ok(CreateUploadResponse {\n        file_id: req.file_id,\n        upload_id,\n      }),\n    }\n  }\n\n  async fn upload_part(\n    &self,\n    object_key: &str,\n    req: UploadPartData,\n  ) -> Result<UploadPartResponse, AppError> {\n    if req.body.is_empty() {\n      return Err(AppError::InvalidRequest(\"body is empty\".to_string()));\n    }\n    trace!(\"multi-part upload to s3: {} - {}\", object_key, req,);\n    let body = ByteStream::from(req.body);\n    let upload_part_res = self\n      .client\n      .upload_part()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .upload_id(&req.upload_id)\n      .part_number(req.part_number)\n      .body(body)\n      .send()\n      .await\n      .map_err(|err| anyhow!(format!(\"Failed to upload part: {:?}\", err)))?;\n\n    match upload_part_res.e_tag {\n      None => Err(anyhow!(\"Failed to upload part: e_tag is None\").into()),\n      Some(e_tag) => Ok(UploadPartResponse {\n        part_num: req.part_number,\n        e_tag,\n      }),\n    }\n  }\n\n  /// Return the content length and content type of the uploaded object\n  async fn complete_upload(\n    &self,\n    object_key: &str,\n    req: CompleteUploadRequest,\n  ) -> Result<(usize, String), AppError> {\n    let parts = req\n      .parts\n      .into_iter()\n      .map(|part| {\n        CompletedPart::builder()\n          .e_tag(part.e_tag)\n          .part_number(part.part_number)\n          .build()\n      })\n      .collect::<Vec<_>>();\n    let completed_multipart_upload = CompletedMultipartUpload::builder()\n      .set_parts(Some(parts))\n      .build();\n\n    self\n      .complete_upload_and_get_metadata(object_key, &req.upload_id, completed_multipart_upload)\n      .await\n  }\n\n  async fn remove_dir(&self, parent_dir: &str) -> Result<(), AppError> {\n    let mut continuation_token = None;\n    loop {\n      let list_objects = self\n        .client\n        .list_objects_v2()\n        .bucket(&self.bucket)\n        .prefix(parent_dir)\n        .set_continuation_token(continuation_token.clone())\n        .send()\n        .await\n        .map_err(|err| anyhow!(\"Failed to list object: {}\", err))?;\n\n      let mut objects_to_delete: Vec<ObjectIdentifier> = list_objects\n        .contents\n        .unwrap_or_default()\n        .into_iter()\n        .filter_map(|object| {\n          object.key.and_then(|key| {\n            ObjectIdentifier::builder()\n              .key(key)\n              .build()\n              .map_err(|e| {\n                error!(\"Error building ObjectIdentifier: {:?}\", e);\n                e\n              })\n              .ok()\n          })\n        })\n        .collect();\n\n      trace!(\n        \"deleting {} objects at directory: {}\",\n        objects_to_delete.len(),\n        parent_dir\n      );\n\n      // Step 2: Delete the listed objects in batches of 1000\n      while !objects_to_delete.is_empty() {\n        debug!(\n          \"Deleting batch of {} objects from S3 bucket: {}\",\n          objects_to_delete.len(),\n          self.bucket\n        );\n\n        let batch = if objects_to_delete.len() > 1000 {\n          objects_to_delete.split_off(1000)\n        } else {\n          Vec::new()\n        };\n\n        trace!(\"deleting objects: {:?}\", objects_to_delete);\n        let delete = Delete::builder()\n          .set_objects(Some(objects_to_delete))\n          .build()\n          .map_err(|e| {\n            println!(\"Error building Delete: {:?}\", e);\n            e\n          })\n          .map_err(|err| anyhow!(\"Failed to build delete object: {}\", err))?;\n\n        let delete_objects_output: DeleteObjectsOutput = self\n          .client\n          .delete_objects()\n          .bucket(&self.bucket)\n          .delete(delete)\n          .send()\n          .await\n          .map_err(|err| anyhow!(\"Failed to delete object: {:?}\", err))?;\n\n        if let Some(errors) = delete_objects_output.errors {\n          for error in errors {\n            error!(\"Error deleting object: {:?}\", error);\n          }\n        }\n\n        objects_to_delete = batch;\n      }\n\n      // is_truncated is true if there are more objects to list. If it's false, it means we have listed all objects in the directory\n      match list_objects.is_truncated {\n        None => break,\n        Some(is_truncated) => {\n          if !is_truncated {\n            break;\n          }\n        },\n      }\n\n      continuation_token = list_objects.next_continuation_token;\n    }\n\n    Ok(())\n  }\n\n  async fn list_dir(&self, dir: &str, limit: usize) -> Result<Vec<String>, AppError> {\n    let list_objects = self\n      .client\n      .list_objects_v2()\n      .bucket(&self.bucket)\n      .prefix(dir)\n      .max_keys(limit as i32)\n      .send()\n      .await\n      .map_err(|err| anyhow!(\"Failed to list object: {}\", err))?;\n\n    Ok(\n      list_objects\n        .contents\n        .unwrap_or_default()\n        .into_iter()\n        .filter_map(|o| o.key)\n        .collect(),\n    )\n  }\n}\n\n#[derive(Debug)]\npub struct S3ResponseData {\n  data: Vec<u8>,\n  content_type: Option<String>,\n}\n\nimpl Deref for S3ResponseData {\n  type Target = Vec<u8>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.data\n  }\n}\n\nimpl ResponseBlob for S3ResponseData {\n  fn to_blob(self) -> Vec<u8> {\n    self.data\n  }\n\n  fn content_type(&self) -> Option<String> {\n    self.content_type.clone()\n  }\n}\n\nimpl From<DeleteObjectOutput> for S3ResponseData {\n  fn from(_: DeleteObjectOutput) -> Self {\n    S3ResponseData {\n      data: Vec::new(),\n      content_type: None,\n    }\n  }\n}\n\nimpl From<DeleteObjectsOutput> for S3ResponseData {\n  fn from(_: DeleteObjectsOutput) -> Self {\n    S3ResponseData {\n      data: Vec::new(),\n      content_type: None,\n    }\n  }\n}\n\nimpl S3ResponseData {\n  pub fn new_with_data(data: Vec<u8>, content_type: Option<String>) -> Self {\n    S3ResponseData { data, content_type }\n  }\n}\n"
  },
  {
    "path": "libs/database/src/file/utils.rs",
    "content": "// use base64::Engine;\n// use base64::alphabet::URL_SAFE;\n// use base64::engine::general_purpose::PAD;\n// use base64::engine::GeneralPurpose;\n// use sha2::{Digest, Sha256};\n// use std::pin::Pin;\n// use std::task::{Context, Poll};\n// use tokio::io::{self, AsyncRead, ReadBuf, AsyncReadExt};\n//\n// pub const URL_SAFE_ENGINE: GeneralPurpose = GeneralPurpose::new(&URL_SAFE, PAD);\n// pub struct BlobStreamReader<R> {\n//   reader: R,\n//   hasher: Sha256,\n// }\n//\n// impl<R> AsyncRead for BlobStreamReader<R>\n// where\n//   R: AsyncRead + Unpin,\n// {\n//   fn poll_read(\n//     mut self: Pin<&mut Self>,\n//     cx: &mut Context<'_>,\n//     buf: &mut ReadBuf<'_>,\n//   ) -> Poll<io::Result<()>> {\n//     let before = buf.filled().len();\n//     let poll = Pin::new(&mut self.reader).poll_read(cx, buf);\n//     let after = buf.filled().len();\n//     if after > before {\n//       self.hasher.update(&buf.filled()[before..after]);\n//     }\n//     poll\n//   }\n// }\n//\n// impl<R> BlobStreamReader<R>\n// where\n//   R: AsyncRead + Unpin,\n// {\n//   pub fn new(reader: R) -> Self {\n//     Self {\n//       reader,\n//       hasher: Sha256::new(),\n//     }\n//   }\n//\n//   pub async fn finish(mut self) -> io::Result<(Vec<u8>, String)> {\n//     let mut buffer = Vec::new();\n//     let _ = self.read_to_end(&mut buffer).await?;\n//     let hash = URL_SAFE_ENGINE.encode(self.hasher.finalize());\n//     Ok((buffer, hash))\n//   }\n// }\n//\n// impl<R> AsRef<R> for BlobStreamReader<R>\n// where\n//   R: AsyncRead + Unpin,\n// {\n//   fn as_ref(&self) -> &R {\n//     &self.reader\n//   }\n// }\n"
  },
  {
    "path": "libs/database/src/history/mod.rs",
    "content": "pub mod ops;\n"
  },
  {
    "path": "libs/database/src/history/ops.rs",
    "content": "use crate::collab::partition_key_from_collab_type;\n\nuse collab_entity::CollabType;\nuse serde::{Deserialize, Serialize};\nuse sqlx::{Executor, FromRow, PgPool, Postgres};\nuse std::ops::DerefMut;\nuse tonic_proto::history::{HistoryStatePb, SingleSnapshotInfoPb, SnapshotMetaPb};\nuse tracing::trace;\nuse uuid::Uuid;\n\n#[allow(clippy::too_many_arguments)]\npub async fn insert_history(\n  workspace_id: &Uuid,\n  oid: &Uuid,\n  doc_state: Vec<u8>,\n  doc_state_version: i32,\n  deps_snapshot_id: Option<&Uuid>,\n  collab_type: CollabType,\n  created_at: i64,\n  snapshots: Vec<SnapshotMetaPb>,\n  pool: PgPool,\n) -> Result<(), sqlx::Error> {\n  let mut transaction = pool.begin().await?;\n  let partition_key = partition_key_from_collab_type(&collab_type);\n  let to_insert: Vec<SnapshotMetaPb> = snapshots\n    .into_iter()\n    .filter(|s| s.created_at <= created_at)\n    .collect();\n\n  trace!(\n    \"Inserting {} snapshots into af_snapshot_meta\",\n    to_insert.len()\n  );\n  for snapshot in to_insert {\n    insert_snapshot_meta(\n      workspace_id,\n      oid,\n      snapshot,\n      partition_key,\n      transaction.deref_mut(),\n    )\n    .await?;\n  }\n\n  insert_snapshot_state(\n    workspace_id,\n    oid,\n    doc_state,\n    doc_state_version,\n    deps_snapshot_id,\n    partition_key,\n    created_at,\n    transaction.deref_mut(),\n  )\n  .await?;\n\n  transaction.commit().await?;\n\n  Ok(())\n}\n\nasync fn insert_snapshot_meta<'a, E: Executor<'a, Database = Postgres>>(\n  workspace_id: &Uuid,\n  oid: &Uuid,\n  meta: SnapshotMetaPb,\n  partition_key: i32,\n  executor: E,\n) -> Result<(), sqlx::Error> {\n  sqlx::query!(\n    r#\"\n    INSERT INTO af_snapshot_meta (oid, workspace_id, snapshot, snapshot_version, partition_key, created_at)\n    VALUES ($1, $2, $3, $4, $5, $6)\n    ON CONFLICT DO NOTHING\n    \"#,\n    oid.to_string(),\n    workspace_id,\n    meta.snapshot,\n    meta.snapshot_version,\n    partition_key,\n    meta.created_at,\n  )\n .execute(executor)\n .await?;\n  Ok(())\n}\n/// Retrieves a list of snapshot metadata from the `af_snapshot_meta` table\n/// sorted by the `created_at` timestamp in descending order.\n///\n/// # Parameters\n/// - `oid`: The object identifier used to filter the results.\n/// - `partition_key`: The partition key used to further filter the results.\n/// - `pool`: The PostgreSQL connection pool.\n///\n/// # Returns\n/// Returns a vector of `AFSnapshotMetaPbRow` struct instances containing the snapshot data.\n/// This vector is empty if no records match the criteria.\npub async fn get_snapshot_meta_list(\n  oid: &Uuid,\n  collab_type: &CollabType,\n  pool: &PgPool,\n) -> Result<Vec<AFSnapshotMetaPbRow>, sqlx::Error> {\n  let partition_key = partition_key_from_collab_type(collab_type);\n\n  let rows: Vec<_> = sqlx::query_as!(\n    AFSnapshotMetaPbRow,\n    r#\"\n    SELECT oid, snapshot, snapshot_version, created_at\n    FROM af_snapshot_meta\n    WHERE oid = $1 AND partition_key = $2\n    ORDER BY created_at DESC\"#,\n    oid.to_string(),\n    partition_key\n  )\n  .fetch_all(pool)\n  .await?;\n\n  Ok(rows)\n}\n\n/// Inserts a new record into the `af_snapshot_state` table.\n///\n/// # Parameters\n/// - `workspace_id`: UUID of the workspace.\n/// - `oid`: Object identifier.\n/// - `doc_state`: Byte array representing the document state.\n/// - `doc_state_version`: Version number of the document state.\n/// - `deps_snapshot_id`: Optional UUID of a dependent snapshot.\n/// - `partition_key`: Integer representing the partition where this record should be stored.\n/// - `created_at`: Timestamp when the snapshot was created.\n/// - `executor`: SQLx executor which could be a transaction or direct database connection.\n///\n#[allow(clippy::too_many_arguments)]\nasync fn insert_snapshot_state<'a, E: Executor<'a, Database = Postgres>>(\n  workspace_id: &Uuid,\n  oid: &Uuid,\n  doc_state: Vec<u8>,\n  doc_state_version: i32,\n  deps_snapshot_id: Option<&Uuid>,\n  partition_key: i32,\n  created_at: i64,\n  executor: E,\n) -> Result<(), sqlx::Error> {\n  sqlx::query!(\n    r#\"\n    INSERT INTO af_snapshot_state (oid, workspace_id, doc_state, doc_state_version, deps_snapshot_id, partition_key, created_at)\n    VALUES ($1, $2, $3, $4, $5, $6, $7)\n    \"#,\n    oid.to_string(),\n    workspace_id,\n    doc_state,\n    doc_state_version,\n    deps_snapshot_id,\n    partition_key,\n    created_at,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\n/// Retrieves the most recent snapshot from the `af_snapshot_state` table\n/// that has a `created_at` timestamp greater than or equal to the specified timestamp.\n///\npub async fn get_latest_snapshot_state<'a, E: Executor<'a, Database = Postgres>>(\n  oid: &Uuid,\n  timestamp: i64,\n  collab_type: &CollabType,\n  executor: E,\n) -> Result<Option<AFSnapshotStateRow>, sqlx::Error> {\n  let partition_key = partition_key_from_collab_type(collab_type);\n  let rec = sqlx::query_as!(\n    AFSnapshotStateRow,\n    r#\"\n        SELECT snapshot_id, oid, doc_state, doc_state_version, deps_snapshot_id, created_at\n        FROM af_snapshot_state\n        WHERE oid = $1 AND partition_key = $2 AND created_at >= $3\n        ORDER BY created_at ASC\n        LIMIT 1\n        \"#,\n    oid.to_string(),\n    partition_key,\n    timestamp,\n  )\n  .fetch_optional(executor)\n  .await?;\n  Ok(rec)\n}\n\n/// Gets the latest snapshot for the specified object identifier and partition key.\npub async fn get_latest_snapshot(\n  oid: &Uuid,\n  collab_type: &CollabType,\n  pool: &PgPool,\n) -> Result<Option<SingleSnapshotInfoPb>, sqlx::Error> {\n  let mut transaction = pool.begin().await?;\n  let partition_key = partition_key_from_collab_type(collab_type);\n  // Attempt to fetch the latest snapshot metadata\n  let snapshot_meta = sqlx::query_as!(\n    AFSnapshotMetaPbRow,\n    r#\"\n        SELECT oid, snapshot, snapshot_version, created_at\n        FROM af_snapshot_meta\n        WHERE oid = $1 AND partition_key = $2\n        ORDER BY created_at DESC\n        LIMIT 1\n        \"#,\n    oid.to_string(),\n    partition_key,\n  )\n  .fetch_optional(transaction.deref_mut())\n  .await?;\n\n  // Return None if no metadata found\n  let snapshot_meta = match snapshot_meta {\n    Some(meta) => SnapshotMetaPb {\n      oid: meta.oid,\n      snapshot: meta.snapshot,\n      snapshot_version: meta.snapshot_version,\n      created_at: meta.created_at,\n    },\n    None => return Ok(None),\n  };\n\n  // Fetch the corresponding state using the metadata's created_at timestamp\n  // Return None if no metadata found\n  let snapshot_state = match get_latest_snapshot_state(\n    oid,\n    snapshot_meta.created_at,\n    collab_type,\n    transaction.deref_mut(),\n  )\n  .await?\n  {\n    Some(state) => state,\n    None => return Ok(None),\n  };\n  transaction.commit().await?;\n\n  let history_state = HistoryStatePb {\n    object_id: snapshot_state.oid,\n    doc_state: snapshot_state.doc_state,\n    doc_state_version: snapshot_state.doc_state_version,\n  };\n\n  let snapshot_info = SingleSnapshotInfoPb {\n    snapshot_meta: Some(snapshot_meta),\n    history_state: Some(history_state),\n  };\n\n  Ok(Some(snapshot_info))\n}\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct AFSnapshotMetaPbRow {\n  pub oid: String,\n  pub snapshot: Vec<u8>,\n  pub snapshot_version: i32,\n  pub created_at: i64,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct AFSnapshotStateRow {\n  pub snapshot_id: Uuid,\n  pub oid: String,\n  pub doc_state: Vec<u8>,\n  pub doc_state_version: i32,\n  pub deps_snapshot_id: Option<Uuid>,\n  pub created_at: i64,\n}\n"
  },
  {
    "path": "libs/database/src/index/collab_embeddings_ops.rs",
    "content": "use crate::collab::partition_key_from_collab_type;\nuse chrono::{DateTime, Utc};\nuse collab_entity::CollabType;\nuse database_entity::dto::{AFCollabEmbeddedChunk, IndexingStatus, QueryCollab, QueryCollabParams};\nuse futures_util::stream::BoxStream;\nuse futures_util::StreamExt;\nuse pgvector::Vector;\nuse serde_json::json;\nuse sqlx::pool::PoolConnection;\nuse sqlx::postgres::{PgHasArrayType, PgTypeInfo};\nuse sqlx::{Error, Executor, Postgres, Transaction};\nuse std::collections::HashMap;\nuse std::ops::DerefMut;\nuse uuid::Uuid;\n\npub async fn get_index_status<'a, E>(\n  tx: E,\n  workspace_id: &Uuid,\n  object_id: &Uuid,\n) -> Result<IndexingStatus, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let result = sqlx::query!(\n    r#\"\nSELECT\n  w.settings['disable_search_indexing']::boolean as disable_search_indexing,\n  CASE\n    WHEN w.settings['disable_search_indexing']::boolean THEN\n      FALSE\n    ELSE\n      EXISTS (SELECT 1 FROM af_collab_embeddings m WHERE m.oid = $2::uuid)\n  END as has_index\nFROM af_workspace w\nWHERE w.workspace_id = $1\"#,\n    workspace_id,\n    object_id\n  )\n  .fetch_one(tx)\n  .await;\n  match result {\n    Ok(row) => {\n      if row.disable_search_indexing.unwrap_or(false) {\n        Ok(IndexingStatus::Disabled)\n      } else if row.has_index.unwrap_or(false) {\n        Ok(IndexingStatus::Indexed)\n      } else {\n        Ok(IndexingStatus::NotIndexed)\n      }\n    },\n    Err(Error::RowNotFound) => {\n      tracing::warn!(\n        \"open-collab event for {}/{} arrived before its workspace was created\",\n        workspace_id,\n        object_id\n      );\n      Ok(IndexingStatus::NotIndexed)\n    },\n    Err(e) => Err(e),\n  }\n}\n\n#[derive(sqlx::Type)]\n#[sqlx(type_name = \"af_fragment_v3\", no_pg_array)]\npub struct Fragment {\n  pub fragment_id: String,\n  pub content_type: i32,\n  pub contents: Option<String>,\n  pub embedding: Option<Vector>,\n  pub metadata: serde_json::Value,\n  pub fragment_index: i32,\n  pub embedded_type: i16,\n}\n\nimpl From<AFCollabEmbeddedChunk> for Fragment {\n  fn from(value: AFCollabEmbeddedChunk) -> Self {\n    Fragment {\n      fragment_id: value.fragment_id,\n      content_type: value.content_type as i32,\n      contents: value.content,\n      embedding: value.embedding.map(Vector::from),\n      metadata: value.metadata,\n      fragment_index: value.fragment_index,\n      embedded_type: value.embedded_type,\n    }\n  }\n}\n\nimpl PgHasArrayType for Fragment {\n  fn array_type_info() -> PgTypeInfo {\n    PgTypeInfo::with_name(\"af_fragment_v3[]\")\n  }\n}\n\npub async fn upsert_collab_embeddings(\n  transaction: &mut Transaction<'_, Postgres>,\n  workspace_id: &Uuid,\n  object_id: &Uuid,\n  tokens_used: u32,\n  chunks: Vec<AFCollabEmbeddedChunk>,\n) -> Result<(), sqlx::Error> {\n  let fragments = chunks.into_iter().map(Fragment::from).collect::<Vec<_>>();\n  tracing::trace!(\n    \"[Embedding] upsert {} {} fragments, fragment ids: {:?}\",\n    object_id,\n    fragments.len(),\n    fragments\n      .iter()\n      .map(|v| v.fragment_id.clone())\n      .collect::<Vec<_>>()\n  );\n  sqlx::query(r#\"CALL af_collab_embeddings_upsert($1, $2, $3, $4::af_fragment_v3[])\"#)\n    .bind(*workspace_id)\n    .bind(object_id)\n    .bind(tokens_used as i32)\n    .bind(fragments)\n    .execute(transaction.deref_mut())\n    .await?;\n  Ok(())\n}\n\npub async fn get_collab_embedding_fragment<'a, E>(\n  tx: E,\n  object_id: &Uuid,\n) -> Result<Vec<Fragment>, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let rows = sqlx::query!(\n    r#\"\n    SELECT\n      fragment_id,\n      content_type,\n      content,\n      embedding as \"embedding!: Option<Vector>\",\n      metadata,\n      fragment_index,\n      embedder_type\n    FROM af_collab_embeddings\n    WHERE oid = $1\n    ORDER BY fragment_index\n    \"#,\n    object_id\n  )\n  .fetch_all(tx)\n  .await?;\n\n  let fragments = rows\n    .into_iter()\n    .map(|row| Fragment {\n      fragment_id: row.fragment_id,\n      content_type: row.content_type,\n      contents: row.content,\n      embedding: row.embedding,\n      metadata: row.metadata.unwrap_or_else(|| json!({})),\n      fragment_index: row.fragment_index.unwrap_or(0),\n      embedded_type: row.embedder_type.unwrap_or(0),\n    })\n    .collect();\n\n  Ok(fragments)\n}\n\npub async fn get_collab_embedding_fragment_ids<'a, E>(\n  tx: E,\n  collab_ids: Vec<Uuid>,\n) -> Result<HashMap<Uuid, Vec<String>>, sqlx::Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let records = sqlx::query!(\n    r#\"\n        SELECT oid, fragment_id\n        FROM af_collab_embeddings\n        WHERE oid = ANY($1::uuid[])\n        \"#,\n    &collab_ids,\n  )\n  .fetch_all(tx)\n  .await?;\n\n  let mut fragment_ids_by_oid = HashMap::new();\n  for record in records {\n    // If your record.oid is not a String, convert it as needed.\n    fragment_ids_by_oid\n      .entry(record.oid)\n      .or_insert_with(Vec::new)\n      .push(record.fragment_id);\n  }\n  Ok(fragment_ids_by_oid)\n}\n\npub async fn stream_collabs_without_embeddings(\n  conn: &mut PoolConnection<Postgres>,\n  workspace_id: Uuid,\n  limit: i64,\n) -> BoxStream<sqlx::Result<CollabId>> {\n  sqlx::query!(\n    r#\"\n        SELECT c.workspace_id, c.oid, c.partition_key\n        FROM af_collab c\n        JOIN af_workspace w ON c.workspace_id = w.workspace_id\n        WHERE c.workspace_id = $1\n        AND NOT COALESCE(w.settings['disable_search_indexing']::boolean, false)\n        AND c.indexed_at IS NULL\n        ORDER BY c.updated_at DESC\n        LIMIT $2\n    \"#,\n    workspace_id,\n    limit\n  )\n  .fetch(conn.deref_mut())\n  .map(|row| {\n    row.map(|r| CollabId {\n      collab_type: CollabType::from(r.partition_key),\n      workspace_id: r.workspace_id,\n      object_id: r.oid,\n    })\n  })\n  .boxed()\n}\n\npub async fn update_collab_indexed_at<'a, E>(\n  tx: E,\n  object_id: &Uuid,\n  collab_type: &CollabType,\n  indexed_at: DateTime<Utc>,\n) -> Result<(), Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let partition_key = partition_key_from_collab_type(collab_type);\n  sqlx::query!(\n    r#\"\n      UPDATE af_collab\n      SET indexed_at = $1\n      WHERE oid = $2 AND partition_key = $3\n    \"#,\n    indexed_at,\n    object_id,\n    partition_key\n  )\n  .execute(tx)\n  .await?;\n\n  Ok(())\n}\n\npub async fn get_collabs_indexed_at<'a, E>(\n  executor: E,\n  oids: Vec<Uuid>,\n) -> Result<HashMap<Uuid, DateTime<Utc>>, Error>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  let result = sqlx::query!(\n    r#\"\n        SELECT oid, indexed_at\n        FROM af_collab\n        WHERE oid = ANY (SELECT UNNEST($1::uuid[]))\n        \"#,\n    &oids\n  )\n  .fetch_all(executor)\n  .await?;\n\n  let map = result\n    .into_iter()\n    .filter_map(|r| r.indexed_at.map(|indexed_at| (r.oid, indexed_at)))\n    .collect::<HashMap<Uuid, DateTime<Utc>>>();\n  Ok(map)\n}\n\n#[derive(Debug, Clone)]\npub struct CollabId {\n  pub collab_type: CollabType,\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n}\n\nimpl From<CollabId> for QueryCollabParams {\n  fn from(value: CollabId) -> Self {\n    QueryCollabParams {\n      workspace_id: value.workspace_id,\n      inner: QueryCollab {\n        object_id: value.object_id,\n        collab_type: value.collab_type,\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "libs/database/src/index/mod.rs",
    "content": "mod collab_embeddings_ops;\nmod search_ops;\n\npub use collab_embeddings_ops::*;\npub use search_ops::*;\n"
  },
  {
    "path": "libs/database/src/index/search_ops.rs",
    "content": "use chrono::{DateTime, Utc};\nuse pgvector::Vector;\nuse sqlx::{Executor, Postgres};\nuse tracing::trace;\nuse uuid::Uuid;\n\n/// Logs each search request to track usage by workspace. It either inserts a new record or updates\n/// an existing one with the current date, workspace ID, request count, and token usage. This ensures\n/// accurate usage tracking for billing or monitoring.\n///\n/// Searches and retrieves documents based on their similarity to a given search embedding.\n/// It filters by workspace, user access, and document status, and returns a limited number\n/// of the most relevant documents, sorted by similarity score.\npub async fn search_documents<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  params: SearchDocumentParams,\n  tokens_used: u32,\n) -> Result<Vec<SearchDocumentResult>, sqlx::Error> {\n  let query = sqlx::query_as::<_, SearchDocumentRow>(\n    r#\"\n    WITH workspace AS (\n      INSERT INTO af_workspace_ai_usage(created_at, workspace_id, search_requests, search_tokens_consumed, index_tokens_consumed)\n      VALUES (now()::date, $2, 1, $6, 0)\n      ON CONFLICT (created_at, workspace_id) DO UPDATE\n      SET search_requests = af_workspace_ai_usage.search_requests + 1,\n          search_tokens_consumed = af_workspace_ai_usage.search_tokens_consumed + $6\n      RETURNING workspace_id\n    )\n   SELECT\n    collab.oid AS object_id,\n    collab.workspace_id,\n    collab.partition_key AS collab_type,\n    em.content_type,\n    em.content,\n    LEFT(em.content, $4) AS content_preview,\n    u.name AS created_by,\n    collab.created_at AS created_at,\n    em.embedding <=> $3 AS distance\n    FROM af_collab collab\n    JOIN LATERAL (\n      -- Fetch the most relevant embedding per collab.oid\n      SELECT *\n      FROM af_collab_embeddings\n      WHERE oid = collab.oid\n      ORDER BY embedding <=> $3  -- Use vector index for sorting\n      LIMIT 1  -- Only keep the top result\n    ) em ON true\n    JOIN af_user u ON collab.owner_uid = u.uid\n    WHERE\n      collab.workspace_id = $2\n      AND collab.oid = ANY($7::uuid[])\n    ORDER BY distance\n    LIMIT $5;\n  \"#,\n  )\n  .bind(params.user_id)\n  .bind(params.workspace_id)\n  .bind(Vector::from(params.embedding))\n  .bind(params.preview)\n  .bind(params.limit)\n  .bind(tokens_used as i64)\n  .bind(params.searchable_view_ids);\n  let rows = query.fetch_all(executor).await?;\n  let has_rows = !rows.is_empty();\n  trace!(\n    \"[Search] found {} results, distances: {:?}\",\n    rows.len(),\n    rows.iter().map(|r| r.distance).collect::<Vec<_>>()\n  );\n\n  let mut results = Vec::with_capacity(rows.len());\n  // Process results\n  for result in rows {\n    let score = _cosine_relevance_score_fn(result.distance);\n    if score <= params.score {\n      continue;\n    }\n\n    results.push(SearchDocumentResult {\n      object_id: result.object_id,\n      workspace_id: result.workspace_id,\n      collab_type: result.collab_type,\n      content_type: result.content_type,\n      content: result.content,\n      created_by: result.created_by,\n      created_at: result.created_at,\n      score,\n    });\n  }\n\n  if has_rows {\n    trace!(\n      \"[Search] found {} relevant results, scores: {:?}\",\n      results.len(),\n      results.iter().map(|r| r.score).collect::<Vec<_>>()\n    );\n  }\n\n  Ok(results)\n}\n\n/// Converts cosine distance to a relevance score.\n/// Distance:\n///   Represents the raw vector distance between the query embedding and the document embedding\n///   Uses the PG vector operator <=> (cosine distance)\n///   Lower values indicate higher similarity (0 means identical vectors)\n/// Score:\n///   From user perspective, higher scores are better.\n///   Higher values indicate higher similarity (1 means identical vectors)\n///\nfn _cosine_relevance_score_fn(distance: f64) -> f64 {\n  // Ensure distance is in valid range (0 to 2 for cosine distance)\n  if distance < 0.0 {\n    1.0 // Maximum similarity for invalid negative distances\n  } else if distance > 2.0 {\n    0.0 // Minimum similarity for invalid large distances\n  } else {\n    1.0 - distance\n  }\n}\n\n#[derive(Debug, Clone)]\npub struct SearchDocumentParams {\n  /// ID of the user who is searching.\n  pub user_id: i64,\n  /// Workspace ID to search for documents in.\n  pub workspace_id: Uuid,\n  /// How many results should be returned.\n  pub limit: i32,\n  /// How many characters of the content (starting from the beginning) should be returned.\n  pub preview: i32,\n  /// Embedding of the query - generated by OpenAI embedder.\n  pub embedding: Vec<f32>,\n  /// List of view ids which is not supposed to be returned in the search results.\n  pub searchable_view_ids: Vec<Uuid>,\n  /// similarity score limit for the search results. The higher, the better.\n  pub score: f64,\n}\n\n#[derive(Debug, Clone, sqlx::FromRow)]\npub struct SearchDocumentRow {\n  /// Document identifier.\n  pub object_id: Uuid,\n  /// Workspace identifier, given document belongs to.\n  pub workspace_id: Uuid,\n  /// Partition key, which maps directly onto [collab_entity::CollabType].\n  pub collab_type: i32,\n  /// Type of the content to be presented. Maps directly onto [database_entity::dto::EmbeddingContentType].\n  pub content_type: i32,\n  /// Content of the document.\n  pub content: String,\n  /// Name of the user who's an owner of the document.\n  pub created_by: String,\n  /// When the document was created.\n  pub created_at: DateTime<Utc>,\n  /// Similarity distance to an original query. Lower is better.\n  pub distance: f64,\n}\n\n#[derive(Debug, Clone)]\npub struct SearchDocumentResult {\n  pub object_id: Uuid,\n  pub workspace_id: Uuid,\n  pub collab_type: i32,\n  pub content_type: i32,\n  pub content: String,\n  pub created_by: String,\n  pub created_at: DateTime<Utc>,\n  pub score: f64,\n}\n"
  },
  {
    "path": "libs/database/src/lib.rs",
    "content": "pub mod access_request;\npub mod chat;\npub mod collab;\npub mod file;\npub mod history;\npub mod index;\npub mod listener;\npub mod notification;\npub mod pg_row;\npub mod publish;\npub mod quick_note;\npub mod resource_usage;\npub mod template;\npub mod user;\npub mod workspace;\n"
  },
  {
    "path": "libs/database/src/listener.rs",
    "content": "use anyhow::Error;\nuse serde::de::DeserializeOwned;\nuse sqlx::postgres::PgListener;\nuse sqlx::PgPool;\nuse tokio::sync::broadcast;\nuse tracing::{error, trace};\n\npub struct PostgresDBListener<T: Clone> {\n  pub notify: broadcast::Sender<T>,\n}\n\nimpl<T> PostgresDBListener<T>\nwhere\n  T: Clone + DeserializeOwned + Send + 'static,\n{\n  pub async fn new(pg_pool: &PgPool, channel: &str) -> Result<Self, Error> {\n    let mut listener = PgListener::connect_with(pg_pool).await?;\n    // TODO(nathan): using listen_all\n    listener.listen(channel).await?;\n\n    let (tx, _) = broadcast::channel(1000);\n    let notify = tx.clone();\n    tokio::spawn(async move {\n      while let Ok(notification) = listener.recv().await {\n        trace!(\n          \"Received notification: channel: {}, payload: {}\",\n          notification.channel(),\n          notification.payload()\n        );\n        match serde_json::from_str::<T>(notification.payload()) {\n          Ok(change) => {\n            let _ = tx.send(change);\n          },\n          Err(err) => {\n            error!(\n              \"Failed to deserialize change: {:?}, payload: {}\",\n              err,\n              notification.payload()\n            );\n          },\n        }\n      }\n    });\n    Ok(Self { notify })\n  }\n}\n"
  },
  {
    "path": "libs/database/src/notification.rs",
    "content": "use std::time::Duration;\n\nuse app_error::AppError;\nuse database_entity::dto::{PageMentionNotification, ProcessedPageMentionNotification};\nuse sqlx::{postgres::types::PgInterval, Executor, Postgres, QueryBuilder};\n\npub async fn select_recent_page_mentions<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  recency_seconds: u64,\n  grace_period_seconds: u64, // To cover for cases where previous batch of processing didn't succeed due to server restart or other issues.\n) -> Result<Vec<PageMentionNotification>, AppError> {\n  let interval = PgInterval {\n    months: 0,\n    days: 0,\n    microseconds: Duration::from_secs(recency_seconds + grace_period_seconds).as_micros() as i64,\n  };\n  // Use FOR UPDATE SKIP LOCKED in case there is multiple instances of appflowy cloud running.\n  // For instance, when both old and new server version are running during new deployment.\n  let recent_page_mentions = sqlx::query_as!(\n    PageMentionNotification,\n    r#\"\n      SELECT\n        w.workspace_name AS \"workspace_name!\",\n        pm.workspace_id,\n        pm.view_id,\n        pm.view_name,\n        mentioner.name AS \"mentioner_name!\",\n        mentioner.metadata ->> 'icon_url' AS \"mentioner_avatar_url\",\n        pm.person_id AS \"mentioned_person_id\",\n        mentioned_person.name AS \"mentioned_person_name!\",\n        mentioned_person.email AS \"mentioned_person_email!\",\n        pm.mentioned_at AS \"mentioned_at!\",\n        pm.block_id\n      FROM af_page_mention AS pm\n      JOIN af_workspace AS w ON pm.workspace_id = w.workspace_id\n      JOIN af_user AS mentioned_person\n        ON pm.person_id = mentioned_person.uuid\n      JOIN af_user AS mentioner\n        ON pm.mentioned_by = mentioner.uid\n      WHERE pm.mentioned_at > NOW() - $1::INTERVAL\n      AND require_notification\n      AND NOT notified\n      FOR UPDATE SKIP LOCKED\n    \"#,\n    interval\n  )\n  .fetch_all(executor)\n  .await?;\n  Ok(recent_page_mentions)\n}\n\npub async fn update_page_mention_notification_status<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  successful_notifications: &[ProcessedPageMentionNotification],\n) -> Result<(), AppError> {\n  if successful_notifications.is_empty() {\n    return Ok(());\n  }\n\n  let mut builder: QueryBuilder<Postgres> =\n    QueryBuilder::new(\"UPDATE af_page_mention SET notified = TRUE WHERE (view_id, person_id) IN (\");\n  builder.push_tuples(successful_notifications, |mut b, notified| {\n    b.push_bind(notified.view_id).push_bind(notified.person_id);\n  });\n  builder.push(\")\");\n  builder.build().execute(executor).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/database/src/pg_row.rs",
    "content": "use anyhow::anyhow;\nuse app_error::AppError;\nuse chrono::{DateTime, Utc};\n\nuse database_entity::dto::{\n  AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWebUserWithObfuscatedName, AFWorkspace,\n  AFWorkspaceInvitationStatus, AFWorkspaceMember, AccessRequestMinimal, AccessRequestStatus,\n  AccessRequestWithViewId, AccessRequesterInfo, AccountLink, GlobalComment, QuickNote, Reaction,\n  Template, TemplateCategory, TemplateCategoryMinimal, TemplateCategoryType, TemplateCreator,\n  TemplateCreatorMinimal, TemplateGroup, TemplateMinimal,\n};\nuse serde::{Deserialize, Serialize};\nuse sqlx::FromRow;\nuse uuid::Uuid;\n\n/// Represent the row of the af_workspace table\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, sqlx::Type)]\npub struct AFWorkspaceRow {\n  pub workspace_id: Uuid,\n  pub database_storage_id: Option<Uuid>,\n  pub owner_uid: Option<i64>,\n  pub owner_name: Option<String>,\n  pub owner_email: Option<String>,\n  pub created_at: Option<DateTime<Utc>>,\n  pub workspace_type: i32,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub workspace_name: Option<String>,\n  pub icon: Option<String>,\n}\n\nimpl TryFrom<AFWorkspaceRow> for AFWorkspace {\n  type Error = AppError;\n\n  fn try_from(value: AFWorkspaceRow) -> Result<Self, Self::Error> {\n    let owner_uid = value\n      .owner_uid\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty owner_uid\")))?;\n    let database_storage_id = value\n      .database_storage_id\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty workspace_id\")))?;\n\n    let workspace_name = value.workspace_name.unwrap_or_default();\n    let created_at = value.created_at.unwrap_or_else(Utc::now);\n    let icon = value.icon.unwrap_or_default();\n\n    Ok(Self {\n      workspace_id: value.workspace_id,\n      database_storage_id,\n      owner_uid,\n      owner_name: value.owner_name.unwrap_or_default(),\n      owner_email: value.owner_email.unwrap_or_default(),\n      workspace_type: value.workspace_type,\n      workspace_name,\n      created_at,\n      icon,\n      member_count: None,\n      role: None,\n    })\n  }\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct AFWorkspaceRowWithMemberCountAndRole {\n  pub workspace_id: Uuid,\n  pub database_storage_id: Option<Uuid>,\n  pub owner_uid: Option<i64>,\n  pub owner_name: Option<String>,\n  pub owner_email: Option<String>,\n  pub created_at: Option<DateTime<Utc>>,\n  pub workspace_type: i32,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub workspace_name: Option<String>,\n  pub icon: Option<String>,\n  pub member_count: i64,\n  pub role: i32,\n}\n\nimpl TryFrom<AFWorkspaceRowWithMemberCountAndRole> for AFWorkspace {\n  type Error = AppError;\n\n  fn try_from(value: AFWorkspaceRowWithMemberCountAndRole) -> Result<Self, Self::Error> {\n    let owner_uid = value\n      .owner_uid\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty owner_uid\")))?;\n    let database_storage_id = value\n      .database_storage_id\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty workspace_id\")))?;\n\n    let workspace_name = value.workspace_name.unwrap_or_default();\n    let created_at = value.created_at.unwrap_or_else(Utc::now);\n    let icon = value.icon.unwrap_or_default();\n\n    Ok(Self {\n      workspace_id: value.workspace_id,\n      database_storage_id,\n      owner_uid,\n      owner_name: value.owner_name.unwrap_or_default(),\n      owner_email: value.owner_email.unwrap_or_default(),\n      workspace_type: value.workspace_type,\n      workspace_name,\n      created_at,\n      icon,\n      member_count: Some(value.member_count),\n      role: Some(AFRole::from(value.role)),\n    })\n  }\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize, sqlx::Type)]\npub struct AFWorkspaceWithMemberCountRow {\n  pub workspace_id: Uuid,\n  pub database_storage_id: Option<Uuid>,\n  pub owner_uid: Option<i64>,\n  pub owner_name: Option<String>,\n  pub owner_email: Option<String>,\n  pub created_at: Option<DateTime<Utc>>,\n  pub workspace_type: i32,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub workspace_name: Option<String>,\n  pub icon: Option<String>,\n  pub member_count: i64,\n}\n\nimpl TryFrom<AFWorkspaceWithMemberCountRow> for AFWorkspace {\n  type Error = AppError;\n\n  fn try_from(value: AFWorkspaceWithMemberCountRow) -> Result<Self, Self::Error> {\n    let owner_uid = value\n      .owner_uid\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty owner_uid\")))?;\n    let database_storage_id = value\n      .database_storage_id\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty workspace_id\")))?;\n\n    let workspace_name = value.workspace_name.unwrap_or_default();\n    let created_at = value.created_at.unwrap_or_else(Utc::now);\n    let icon = value.icon.unwrap_or_default();\n\n    Ok(Self {\n      workspace_id: value.workspace_id,\n      database_storage_id,\n      owner_uid,\n      owner_name: value.owner_name.unwrap_or_default(),\n      owner_email: value.owner_email.unwrap_or_default(),\n      workspace_type: value.workspace_type,\n      workspace_name,\n      created_at,\n      icon,\n      member_count: Some(value.member_count),\n      role: None,\n    })\n  }\n}\n\n/// Represent the row of the af_user table\n#[derive(Debug, FromRow, Deserialize, Serialize, Clone)]\npub struct AFUserRow {\n  pub uid: i64,\n  pub uuid: Option<Uuid>,\n  pub email: Option<String>,\n  pub password: Option<String>,\n  pub name: Option<String>,\n  pub metadata: Option<serde_json::Value>,\n  pub encryption_sign: Option<String>,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub created_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, FromRow)]\npub struct AFUserIdRow {\n  pub uid: i64,\n  pub uuid: Uuid,\n}\n\n/// Represent the row of the af_user_profile_view\n#[derive(Debug, FromRow, Deserialize, Serialize)]\npub struct AFUserProfileRow {\n  pub uid: Option<i64>,\n  pub uuid: Option<Uuid>,\n  pub email: Option<String>,\n  pub password: Option<String>,\n  pub name: Option<String>,\n  pub metadata: Option<serde_json::Value>,\n  pub encryption_sign: Option<String>,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub updated_at: Option<DateTime<Utc>>,\n  pub created_at: Option<DateTime<Utc>>,\n  pub latest_workspace_id: Option<Uuid>,\n}\n\nimpl TryFrom<AFUserProfileRow> for AFUserProfile {\n  type Error = AppError;\n\n  fn try_from(value: AFUserProfileRow) -> Result<Self, Self::Error> {\n    let uid = value\n      .uid\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty uid\")))?;\n    let uuid = value\n      .uuid\n      .ok_or(AppError::Internal(anyhow!(\"Unexpected empty uuid\")))?;\n    let latest_workspace_id = value.latest_workspace_id.ok_or(AppError::Internal(anyhow!(\n      \"Unexpected empty latest_workspace_id\"\n    )))?;\n    Ok(Self {\n      uid,\n      uuid,\n      email: value.email,\n      password: value.password,\n      name: value.name,\n      metadata: value.metadata,\n      encryption_sign: value.encryption_sign,\n      latest_workspace_id,\n      updated_at: value.updated_at.map(|v| v.timestamp()).unwrap_or(0),\n    })\n  }\n}\n\n#[derive(FromRow, Serialize, Deserialize)]\npub struct AFWorkspaceMemberPermRow {\n  pub uid: i64,\n  pub role: AFRole,\n  pub workspace_id: Uuid,\n}\n\n#[derive(Debug, FromRow, Serialize, Deserialize)]\npub struct AFWorkspaceMemberRow {\n  pub uid: i64,\n  pub name: String,\n  pub email: String,\n  pub avatar_url: Option<String>,\n  pub role: AFRole,\n  pub created_at: Option<DateTime<Utc>>,\n}\n\nimpl From<AFWorkspaceMemberRow> for AFWorkspaceMember {\n  fn from(value: AFWorkspaceMemberRow) -> Self {\n    AFWorkspaceMember {\n      name: value.name.clone(),\n      email: value.email.clone(),\n      role: value.role.clone(),\n      avatar_url: value.avatar_url.clone(),\n      joined_at: value.created_at,\n    }\n  }\n}\n\n#[derive(FromRow)]\npub struct AFCollabMemberAccessLevelRow {\n  pub uid: i64,\n  pub oid: String,\n  pub access_level: AFAccessLevel,\n}\n\n#[derive(FromRow, Clone, Debug, Serialize, Deserialize)]\npub struct AFCollabMemberRow {\n  pub uid: i64,\n  pub oid: String,\n  pub permission_id: i64,\n}\n\n#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]\n#[repr(i16)]\npub enum AFBlobStatus {\n  Ok = 0,\n  PolicyViolation = 1,\n  Failed = 2,\n  Pending = 3,\n}\n\nimpl From<i16> for AFBlobStatus {\n  fn from(value: i16) -> Self {\n    match value {\n      0 => AFBlobStatus::Ok,\n      1 => AFBlobStatus::PolicyViolation,\n      2 => AFBlobStatus::Failed,\n      3 => AFBlobStatus::Pending,\n      _ => AFBlobStatus::Ok,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]\n#[repr(i16)]\npub enum AFBlobSource {\n  UserUpload = 0,\n  AIGen = 1,\n}\n\nimpl From<i16> for AFBlobSource {\n  fn from(value: i16) -> Self {\n    match value {\n      0 => AFBlobSource::UserUpload,\n      1 => AFBlobSource::AIGen,\n      _ => AFBlobSource::UserUpload,\n    }\n  }\n}\n\n#[derive(Debug, FromRow, Serialize, Deserialize)]\npub struct AFBlobMetadataRow {\n  pub workspace_id: Uuid,\n  pub file_id: String,\n  pub file_type: String,\n  pub file_size: i64,\n  pub modified_at: DateTime<Utc>,\n  #[serde(default)]\n  pub status: i16,\n  #[serde(default)]\n  pub source: i16,\n  #[serde(default)]\n  pub source_metadata: serde_json::Value,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct AFUserNotification {\n  pub payload: Option<AFUserRow>,\n}\n\n#[derive(FromRow, Debug, Clone)]\npub struct AFPermissionRow {\n  pub id: i32,\n  pub name: String,\n  pub access_level: AFAccessLevel,\n  pub description: Option<String>,\n}\n\n#[derive(FromRow, Serialize, Deserialize)]\npub struct AFSnapshotRow {\n  pub sid: i64,\n  pub oid: String,\n  pub blob: Vec<u8>,\n  pub len: Option<i32>,\n  pub encrypt: Option<i32>,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub created_at: DateTime<Utc>,\n  pub workspace_id: Uuid,\n}\n\n#[derive(Debug, FromRow, Deserialize, Serialize)]\npub struct AFWorkspaceInvitationMinimal {\n  pub workspace_id: Uuid,\n  pub inviter_uid: i64,\n  pub invitee_uid: Option<i64>,\n  pub status: AFWorkspaceInvitationStatus,\n  pub role: AFRole,\n}\n\n#[derive(FromRow, Clone, Debug)]\npub struct AFCollabRowMeta {\n  pub oid: String,\n  pub workspace_id: Uuid,\n  pub owner_uid: i64,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub created_at: Option<DateTime<Utc>>,\n  pub updated_at: DateTime<Utc>,\n}\n\n#[derive(FromRow, Clone, Debug)]\npub struct AFCollabData {\n  pub oid: Uuid,\n  pub partition_key: i32,\n  pub updated_at: DateTime<Utc>,\n  pub blob: Vec<u8>,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct AFChatRow {\n  pub chat_id: Uuid,\n  pub name: String,\n  pub created_at: DateTime<Utc>,\n  pub deleted_at: Option<DateTime<Utc>>,\n  pub rag_ids: serde_json::Value,\n  pub workspace_id: Uuid,\n  pub meta_data: serde_json::Value,\n}\n\n#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]\npub struct AFChatMessageRow {\n  pub message_id: i64,\n  pub chat_id: Uuid,\n  pub content: String,\n  pub created_at: DateTime<Utc>,\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\npub struct AFWebUserColumn {\n  uuid: Uuid,\n  name: String,\n  avatar_url: Option<String>,\n}\n\nimpl From<AFWebUserColumn> for AFWebUser {\n  fn from(val: AFWebUserColumn) -> Self {\n    AFWebUser {\n      uuid: val.uuid,\n      name: val.name,\n      avatar_url: val.avatar_url,\n    }\n  }\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\npub struct AFWebUserWithEmailColumn {\n  uuid: Uuid,\n  name: String,\n  email: String,\n  avatar_url: Option<String>,\n}\n\nfn mask_web_user_email(email: &str) -> String {\n  email\n    .split('@')\n    .next()\n    .map(|part| part.chars().take(6).collect())\n    .unwrap_or_default()\n}\n\nimpl From<AFWebUserWithEmailColumn> for AFWebUserWithObfuscatedName {\n  fn from(val: AFWebUserWithEmailColumn) -> Self {\n    let obfuscated_name = if val.name == val.email {\n      mask_web_user_email(&val.email)\n    } else {\n      val.name.clone()\n    };\n\n    AFWebUserWithObfuscatedName {\n      uuid: val.uuid,\n      name: obfuscated_name,\n      avatar_url: val.avatar_url,\n    }\n  }\n}\n\npub struct AFGlobalCommentRow {\n  pub user: Option<AFWebUserWithEmailColumn>,\n  pub created_at: DateTime<Utc>,\n  pub last_updated_at: DateTime<Utc>,\n  pub content: String,\n  pub reply_comment_id: Option<Uuid>,\n  pub comment_id: Uuid,\n  pub is_deleted: bool,\n  pub can_be_deleted: bool,\n}\n\nimpl From<AFGlobalCommentRow> for GlobalComment {\n  fn from(val: AFGlobalCommentRow) -> Self {\n    GlobalComment {\n      user: val.user.map(|x| x.into()),\n      created_at: val.created_at,\n      last_updated_at: val.last_updated_at,\n      content: val.content,\n      reply_comment_id: val.reply_comment_id,\n      comment_id: val.comment_id,\n      is_deleted: val.is_deleted,\n      can_be_deleted: val.can_be_deleted,\n    }\n  }\n}\n\npub struct AFReactionRow {\n  pub reaction_type: String,\n  pub react_users: Vec<AFWebUserWithEmailColumn>,\n  pub comment_id: Uuid,\n}\n\nimpl From<AFReactionRow> for Reaction {\n  fn from(val: AFReactionRow) -> Self {\n    Reaction {\n      reaction_type: val.reaction_type,\n      react_users: val.react_users.into_iter().map(|x| x.into()).collect(),\n      comment_id: val.comment_id,\n    }\n  }\n}\n\n#[derive(Debug, FromRow, Serialize, sqlx::Type)]\npub struct AFTemplateCategoryRow {\n  pub category_id: Uuid,\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n  pub description: String,\n  pub category_type: AFTemplateCategoryTypeColumn,\n  pub priority: i32,\n}\n\nimpl From<AFTemplateCategoryRow> for TemplateCategory {\n  fn from(value: AFTemplateCategoryRow) -> Self {\n    Self {\n      id: value.category_id,\n      name: value.name,\n      icon: value.icon,\n      bg_color: value.bg_color,\n      description: value.description,\n      category_type: value.category_type.into(),\n      priority: value.priority,\n    }\n  }\n}\n\n#[derive(Debug, FromRow, Serialize, sqlx::Type)]\n#[sqlx(type_name = \"template_category_minimal_type\")]\npub struct AFTemplateCategoryMinimalRow {\n  pub category_id: Uuid,\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n}\n\nimpl From<AFTemplateCategoryMinimalRow> for TemplateCategoryMinimal {\n  fn from(value: AFTemplateCategoryMinimalRow) -> Self {\n    Self {\n      id: value.category_id,\n      name: value.name,\n      icon: value.icon,\n      bg_color: value.bg_color,\n    }\n  }\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\n#[repr(i32)]\npub enum AFTemplateCategoryTypeColumn {\n  UseCase = 0,\n  Feature = 1,\n}\n\nimpl From<AFTemplateCategoryTypeColumn> for TemplateCategoryType {\n  fn from(value: AFTemplateCategoryTypeColumn) -> Self {\n    match value {\n      AFTemplateCategoryTypeColumn::UseCase => TemplateCategoryType::UseCase,\n      AFTemplateCategoryTypeColumn::Feature => TemplateCategoryType::Feature,\n    }\n  }\n}\n\nimpl From<TemplateCategoryType> for AFTemplateCategoryTypeColumn {\n  fn from(val: TemplateCategoryType) -> Self {\n    match val {\n      TemplateCategoryType::UseCase => AFTemplateCategoryTypeColumn::UseCase,\n      TemplateCategoryType::Feature => AFTemplateCategoryTypeColumn::Feature,\n    }\n  }\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\n#[sqlx(type_name = \"account_link_type\")]\npub struct AccountLinkColumn {\n  pub link_type: String,\n  pub url: String,\n}\n\nimpl From<AccountLinkColumn> for AccountLink {\n  fn from(value: AccountLinkColumn) -> Self {\n    Self {\n      link_type: value.link_type,\n      url: value.url,\n    }\n  }\n}\n\n#[derive(Debug, Serialize, sqlx::Type)]\n#[sqlx(type_name = \"template_creator_type\")]\npub struct AFTemplateCreatorRow {\n  pub id: Uuid,\n  pub name: String,\n  pub avatar_url: String,\n  pub account_links: Option<Vec<AccountLinkColumn>>,\n  pub number_of_templates: i32,\n}\n\nimpl From<AFTemplateCreatorRow> for TemplateCreator {\n  fn from(value: AFTemplateCreatorRow) -> Self {\n    let account_links = value\n      .account_links\n      .unwrap_or_default()\n      .into_iter()\n      .map(|v| v.into())\n      .collect();\n    Self {\n      id: value.id,\n      name: value.name,\n      avatar_url: value.avatar_url,\n      account_links,\n      number_of_templates: value.number_of_templates,\n    }\n  }\n}\n\n#[derive(Debug, Serialize, sqlx::Type)]\n#[sqlx(type_name = \"template_creator_minimal_type\")]\npub struct AFTemplateCreatorMinimalColumn {\n  pub creator_id: Uuid,\n  pub name: String,\n  pub avatar_url: String,\n}\n\nimpl From<AFTemplateCreatorMinimalColumn> for TemplateCreatorMinimal {\n  fn from(value: AFTemplateCreatorMinimalColumn) -> Self {\n    Self {\n      id: value.creator_id,\n      name: value.name,\n      avatar_url: value.avatar_url,\n    }\n  }\n}\n\n#[derive(Debug, Serialize, FromRow, sqlx::Type)]\n#[sqlx(type_name = \"template_minimal_type\")]\npub struct AFTemplateMinimalRow {\n  pub view_id: Uuid,\n  pub created_at: DateTime<Utc>,\n  pub updated_at: DateTime<Utc>,\n  pub name: String,\n  pub description: String,\n  pub view_url: String,\n  pub creator: AFTemplateCreatorMinimalColumn,\n  pub categories: Vec<AFTemplateCategoryMinimalRow>,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n}\n\nimpl From<AFTemplateMinimalRow> for TemplateMinimal {\n  fn from(value: AFTemplateMinimalRow) -> Self {\n    Self {\n      view_id: value.view_id,\n      created_at: value.created_at,\n      last_updated_at: value.updated_at,\n      name: value.name,\n      description: value.description,\n      creator: value.creator.into(),\n      categories: value.categories.into_iter().map(|x| x.into()).collect(),\n      view_url: value.view_url,\n      is_new_template: value.is_new_template,\n      is_featured: value.is_featured,\n    }\n  }\n}\n\n#[derive(Debug, Serialize, sqlx::Type)]\npub struct AFTemplateRow {\n  pub view_id: Uuid,\n  pub created_at: DateTime<Utc>,\n  pub updated_at: DateTime<Utc>,\n  pub name: String,\n  pub description: String,\n  pub about: String,\n  pub view_url: String,\n  pub creator: AFTemplateCreatorRow,\n  pub categories: Vec<AFTemplateCategoryRow>,\n  pub related_templates: Vec<AFTemplateMinimalRow>,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n}\n\nimpl From<AFTemplateRow> for Template {\n  fn from(value: AFTemplateRow) -> Self {\n    let mut related_templates: Vec<TemplateMinimal> = value\n      .related_templates\n      .into_iter()\n      .map(|v| v.into())\n      .collect();\n    related_templates.sort_by_key(|t| t.created_at);\n    related_templates.reverse();\n\n    Self {\n      view_id: value.view_id,\n      created_at: value.created_at,\n      last_updated_at: value.updated_at,\n      name: value.name,\n      description: value.description,\n      about: value.about,\n      view_url: value.view_url,\n      creator: value.creator.into(),\n      categories: value.categories.into_iter().map(|v| v.into()).collect(),\n      related_templates,\n      is_new_template: value.is_new_template,\n      is_featured: value.is_featured,\n    }\n  }\n}\n\n#[derive(Debug, Serialize, sqlx::Type)]\npub struct AFTemplateGroupRow {\n  pub category: AFTemplateCategoryMinimalRow,\n  pub templates: Vec<AFTemplateMinimalRow>,\n}\n\nimpl From<AFTemplateGroupRow> for TemplateGroup {\n  fn from(value: AFTemplateGroupRow) -> Self {\n    let mut templates: Vec<TemplateMinimal> =\n      value.templates.into_iter().map(|v| v.into()).collect();\n    templates.sort_by_key(|t| t.created_at);\n    templates.reverse();\n    Self {\n      category: value.category.into(),\n      templates,\n    }\n  }\n}\n\n#[derive(Debug, FromRow, Serialize, Deserialize)]\npub struct AFImportTask {\n  pub task_id: Uuid,\n  pub file_size: i64,\n  pub workspace_id: String,\n  pub created_by: i64,\n  pub status: i16,\n  pub metadata: serde_json::Value,\n  pub created_at: DateTime<Utc>,\n  #[serde(default)]\n  pub file_url: Option<String>,\n}\n#[derive(sqlx::Type, Serialize, Deserialize, Debug)]\n#[repr(i32)]\npub enum AFAccessRequestStatusColumn {\n  Pending = 0,\n  Approved = 1,\n  Rejected = 2,\n}\n\nimpl From<AFAccessRequestStatusColumn> for AccessRequestStatus {\n  fn from(value: AFAccessRequestStatusColumn) -> Self {\n    match value {\n      AFAccessRequestStatusColumn::Pending => AccessRequestStatus::Pending,\n      AFAccessRequestStatusColumn::Approved => AccessRequestStatus::Approved,\n      AFAccessRequestStatusColumn::Rejected => AccessRequestStatus::Rejected,\n    }\n  }\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\npub struct AFAccessRequesterColumn {\n  pub uid: i64,\n  pub uuid: Uuid,\n  pub name: String,\n  pub email: String,\n  pub avatar_url: Option<String>,\n}\n\nimpl From<AFAccessRequesterColumn> for AccessRequesterInfo {\n  fn from(value: AFAccessRequesterColumn) -> Self {\n    Self {\n      uid: value.uid,\n      uuid: value.uuid,\n      name: value.name,\n      email: value.email,\n      avatar_url: value.avatar_url,\n    }\n  }\n}\n\n#[derive(sqlx::Type, Serialize, Debug)]\npub struct AFAccessRequestMinimalColumn {\n  pub request_id: Uuid,\n  pub workspace_id: Uuid,\n  pub requester_id: Uuid,\n  pub view_id: Uuid,\n}\n\nimpl From<AFAccessRequestMinimalColumn> for AccessRequestMinimal {\n  fn from(value: AFAccessRequestMinimalColumn) -> Self {\n    Self {\n      request_id: value.request_id,\n      workspace_id: value.workspace_id,\n      requester_id: value.requester_id,\n      view_id: value.view_id,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AFAccessRequestWithViewIdColumn {\n  pub request_id: Uuid,\n  pub workspace: AFWorkspaceWithMemberCountRow,\n  pub requester: AccessRequesterInfo,\n  pub view_id: Uuid,\n  pub status: AFAccessRequestStatusColumn,\n  pub created_at: DateTime<Utc>,\n}\n\nimpl TryFrom<AFAccessRequestWithViewIdColumn> for AccessRequestWithViewId {\n  type Error = anyhow::Error;\n\n  fn try_from(value: AFAccessRequestWithViewIdColumn) -> Result<Self, Self::Error> {\n    Ok(Self {\n      request_id: value.request_id,\n      workspace: value.workspace.try_into()?,\n      requester: value.requester,\n      view_id: value.view_id,\n      status: value.status.into(),\n      created_at: value.created_at,\n    })\n  }\n}\n\n#[derive(FromRow, Serialize, Debug)]\npub struct AFQuickNoteRow {\n  pub quick_note_id: Uuid,\n  pub data: serde_json::Value,\n  pub created_at: DateTime<Utc>,\n  pub updated_at: DateTime<Utc>,\n}\n\nimpl From<AFQuickNoteRow> for QuickNote {\n  fn from(value: AFQuickNoteRow) -> Self {\n    Self {\n      id: value.quick_note_id,\n      data: value.data,\n      created_at: value.created_at,\n      last_updated_at: value.updated_at,\n    }\n  }\n}\n\npub struct AFPublishViewWithPublishInfo {\n  pub view_id: Uuid,\n  pub publish_name: String,\n  pub publisher_email: String,\n  pub publish_timestamp: DateTime<Utc>,\n  pub comments_enabled: bool,\n  pub duplicate_enabled: bool,\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n\n  #[test]\n  fn test_mask_web_user_email() {\n    let name = \"\";\n    let masked = mask_web_user_email(name);\n    assert_eq!(masked, \"\");\n\n    let name = \"john@domain.com\";\n    let masked = mask_web_user_email(name);\n    assert_eq!(masked, \"john\");\n\n    let name = \"jonathan@domain.com\";\n    let masked = mask_web_user_email(name);\n    assert_eq!(masked, \"jonath\");\n  }\n}\n"
  },
  {
    "path": "libs/database/src/publish.rs",
    "content": "use app_error::AppError;\nuse database_entity::dto::{\n  PatchPublishedCollab, PublishCollabItem, PublishCollabKey, PublishInfo, WorkspaceNamespace,\n};\nuse sqlx::{Executor, PgPool, Postgres, QueryBuilder};\nuse uuid::Uuid;\n\nuse crate::pg_row::AFPublishViewWithPublishInfo;\n\npub async fn select_user_is_collab_publisher_for_all_views(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  workspace_uuid: &Uuid,\n  view_ids: &[Uuid],\n) -> Result<bool, AppError> {\n  let count = sqlx::query_scalar!(\n    r#\"\n      SELECT COUNT(*)\n      FROM af_published_collab\n      WHERE workspace_id = $1\n        AND view_id = ANY($2)\n        AND published_by = (SELECT uid FROM af_user WHERE uuid = $3)\n    \"#,\n    workspace_uuid,\n    view_ids,\n    user_uuid,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  match count {\n    Some(c) => Ok(c == view_ids.len() as i64),\n    None => Ok(false),\n  }\n}\n\n#[inline]\npub async fn select_workspace_publish_namespace_exists<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  namespace: &str,\n) -> Result<bool, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT EXISTS(\n        SELECT 1\n        FROM af_workspace_namespace\n        WHERE namespace = $1\n      )\n    \"#,\n    namespace,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res.unwrap_or(false))\n}\n\n#[inline]\npub async fn insert_non_orginal_workspace_publish_namespace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  new_namespace: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      INSERT INTO af_workspace_namespace\n      VALUES ($1, $2, FALSE)\n    \"#,\n    new_namespace,\n    workspace_id,\n  )\n  .execute(pg_pool)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to insert workspace publish namespace, workspace_id: {}, new_namespace: {}, rows_affected: {}\",\n      workspace_id, new_namespace, res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn update_non_orginal_workspace_publish_namespace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  old_namespace: &str,\n  new_namespace: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE af_workspace_namespace\n      SET namespace = $1\n      WHERE workspace_id = $2\n        AND namespace = $3\n        AND is_original = FALSE\n    \"#,\n    new_namespace,\n    workspace_id,\n    old_namespace,\n  )\n  .execute(pg_pool)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to update workspace publish namespace, workspace_id: {}, new_namespace: {}, rows_affected: {}\",\n      workspace_id, new_namespace, res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn update_workspace_default_publish_view<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  new_view_id: &Uuid,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE af_workspace\n      SET default_published_view_id = $1\n      WHERE workspace_id = $2\n    \"#,\n    new_view_id,\n    workspace_id,\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n        \"Failed to update workspace default publish view, workspace_id: {}, new_view_id: {}, rows_affected: {}\",\n        workspace_id, new_view_id, res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn update_workspace_default_publish_view_set_null<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE af_workspace\n      SET default_published_view_id = NULL\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to unset workspace default publish view, workspace_id: {}, rows_affected: {}\",\n      workspace_id,\n      res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn select_workspace_publish_namespaces(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Vec<WorkspaceNamespace>, AppError> {\n  let res = sqlx::query_as!(\n    WorkspaceNamespace,\n    r#\"\n      SELECT workspace_id, namespace, is_original\n      FROM af_workspace_namespace\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n\n  Ok(res)\n}\n\n#[inline]\npub async fn select_workspace_publish_namespace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  namespace: &str,\n) -> Result<WorkspaceNamespace, AppError> {\n  let res = sqlx::query_as!(\n    WorkspaceNamespace,\n    r#\"\n      SELECT workspace_id, namespace, is_original\n      FROM af_workspace_namespace\n      WHERE workspace_id = $1\n        AND namespace = $2\n    \"#,\n    workspace_id,\n    namespace,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(res)\n}\n\nasync fn delete_published_collabs(\n  txn: &mut sqlx::Transaction<'_, Postgres>,\n  workspace_id: &Uuid,\n  publish_names: &[String],\n) -> Result<(), AppError> {\n  let delete_publish_names = sqlx::query_scalar!(\n    r#\"\n      DELETE FROM af_published_collab\n      WHERE workspace_id = $1\n        AND publish_name = ANY($2::text[])\n      RETURNING publish_name\n    \"#,\n    workspace_id,\n    &publish_names,\n  )\n  .fetch_all(txn.as_mut())\n  .await?;\n  if !delete_publish_names.is_empty() {\n    tracing::info!(\n      \"Deleted existing published collab record with publish names: {:?}\",\n      delete_publish_names\n    );\n  }\n  Ok(())\n}\n\n#[inline]\npub async fn insert_or_replace_publish_collabs(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  publisher_uuid: &Uuid,\n  publish_items: Vec<PublishCollabItem<serde_json::Value, Vec<u8>>>,\n) -> Result<(), AppError> {\n  let item_count = publish_items.len();\n  let mut view_ids: Vec<Uuid> = Vec::with_capacity(item_count);\n  let mut publish_names: Vec<String> = Vec::with_capacity(item_count);\n  let mut metadatas: Vec<serde_json::Value> = Vec::with_capacity(item_count);\n  let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(item_count);\n  let mut comments_enabled_list: Vec<bool> = Vec::with_capacity(item_count);\n  let mut duplicate_enabled_list: Vec<bool> = Vec::with_capacity(item_count);\n  publish_items.into_iter().for_each(|item| {\n    view_ids.push(item.meta.view_id);\n    publish_names.push(item.meta.publish_name);\n    metadatas.push(item.meta.metadata);\n    blobs.push(item.data);\n    comments_enabled_list.push(item.comments_enabled);\n    duplicate_enabled_list.push(item.duplicate_enabled);\n  });\n\n  let mut txn = pg_pool.begin().await?;\n  delete_published_collabs(&mut txn, workspace_id, &publish_names).await?;\n\n  let res = sqlx::query!(\n    r#\"\n      INSERT INTO af_published_collab (workspace_id, view_id, publish_name, published_by, metadata, blob, comments_enabled, duplicate_enabled)\n      SELECT * FROM UNNEST(\n        (SELECT array_agg((SELECT $1::uuid)) FROM generate_series(1, $9))::uuid[],\n        $2::uuid[],\n        $3::text[],\n        (SELECT array_agg((SELECT uid FROM af_user WHERE uuid = $4)) FROM generate_series(1, $9))::bigint[],\n        $5::jsonb[],\n        $6::bytea[],\n        $7::boolean[],\n        $8::boolean[]\n      )\n      ON CONFLICT (workspace_id, view_id) DO UPDATE\n      SET metadata = EXCLUDED.metadata,\n          blob = EXCLUDED.blob,\n          published_by = EXCLUDED.published_by,\n          publish_name = EXCLUDED.publish_name\n    \"#,\n    workspace_id,\n    &view_ids,\n    &publish_names,\n    publisher_uuid,\n    &metadatas,\n    &blobs,\n    &comments_enabled_list,\n    &duplicate_enabled_list,\n    item_count as i32,\n  )\n  .execute(txn.as_mut())\n  .await?;\n\n  if res.rows_affected() != item_count as u64 {\n    tracing::warn!(\n      \"Failed to insert or replace publish collab meta batch, workspace_id: {}, publisher_uuid: {}, rows_affected: {}, item_count: {}\",\n      workspace_id, publisher_uuid, res.rows_affected(), item_count\n    );\n  }\n\n  txn.commit().await?;\n  Ok(())\n}\n\n#[inline]\npub async fn select_publish_collab_meta<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  publish_namespace: &str,\n  publish_name: &str,\n) -> Result<serde_json::Value, AppError> {\n  let res = sqlx::query!(\n    r#\"\n    SELECT metadata\n    FROM af_published_collab\n    WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n      AND unpublished_at IS NULL\n      AND publish_name = $2\n    \"#,\n    publish_namespace,\n    publish_name,\n  )\n  .fetch_one(executor)\n  .await?;\n  let metadata: serde_json::Value = res.metadata;\n  Ok(metadata)\n}\n\n#[inline]\npub async fn set_published_collabs_as_unpublished<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  view_ids: &[Uuid],\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE af_published_collab\n      SET\n        blob = E''::bytea,\n        unpublished_at = NOW()\n      WHERE workspace_id = $1\n        AND view_id = ANY($2)\n    \"#,\n    workspace_id,\n    view_ids,\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != view_ids.len() as u64 {\n    tracing::error!(\n      \"Failed to delete published collabs, workspace_id: {}, view_ids: {:?}, rows_affected: {}\",\n      workspace_id,\n      view_ids,\n      res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn update_published_collabs(\n  txn: &mut sqlx::Transaction<'_, Postgres>,\n  workspace_id: &Uuid,\n  patches: &[PatchPublishedCollab],\n) -> Result<(), AppError> {\n  {\n    // Delete existing published collab records with the same publish names\n    let publish_names: Vec<String> = patches\n      .iter()\n      .filter_map(|patch| patch.publish_name.clone())\n      .collect();\n    delete_published_collabs(txn, workspace_id, &publish_names).await?;\n  }\n  for patch in patches {\n    let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(\n      r#\"\n        UPDATE af_published_collab SET\n      \"#,\n    );\n    let mut first_set = true;\n    if let Some(comments_enabled) = patch.comments_enabled {\n      first_set = false;\n      query_builder.push(\" comments_enabled = \");\n      query_builder.push_bind(comments_enabled);\n    }\n    if let Some(duplicate_enabled) = patch.duplicate_enabled {\n      if !first_set {\n        query_builder.push(\",\");\n      }\n      first_set = false;\n      query_builder.push(\" duplicate_enabled = \");\n      query_builder.push_bind(duplicate_enabled);\n    }\n    if let Some(publish_name) = &patch.publish_name {\n      if !first_set {\n        query_builder.push(\",\");\n      }\n      query_builder.push(\" publish_name = \");\n      query_builder.push_bind(publish_name);\n    }\n    query_builder.push(\" WHERE workspace_id = \");\n    query_builder.push_bind(workspace_id);\n    query_builder.push(\" AND view_id = \");\n    query_builder.push_bind(patch.view_id);\n    let query = query_builder.build();\n    let res = query.execute(txn.as_mut()).await?;\n\n    if res.rows_affected() != 1 {\n      tracing::error!(\n          \"Failed to update published collab publish name, workspace_id: {}, view_id: {}, rows_affected: {}\",\n          workspace_id,\n          patch.view_id,\n          res.rows_affected()\n        );\n    }\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn select_published_metadata_for_view_id(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n) -> Result<Option<(Uuid, serde_json::Value)>, AppError> {\n  let res = sqlx::query!(\n    r#\"\n      SELECT workspace_id, metadata\n      FROM af_published_collab\n      WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n  Ok(res.map(|res| (res.workspace_id, res.metadata)))\n}\n\n#[inline]\npub async fn select_published_data_for_view_id(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n) -> Result<Option<(serde_json::Value, Vec<u8>)>, AppError> {\n  let res = sqlx::query!(\n    r#\"\n      SELECT metadata, blob\n      FROM af_published_collab\n      WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n  Ok(res.map(|res| (res.metadata, res.blob)))\n}\n\n#[inline]\npub async fn select_published_collab_workspace_view_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  publish_namespace: &str,\n  publish_name: &str,\n) -> Result<PublishCollabKey, AppError> {\n  let key = sqlx::query_as!(\n    PublishCollabKey,\n    r#\"\n      SELECT workspace_id, view_id\n      FROM af_published_collab\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n      AND publish_name = $2\n    \"#,\n    publish_namespace,\n    publish_name,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(key)\n}\n\n#[inline]\npub async fn select_published_collab_blob<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  publish_namespace: &str,\n  publish_name: &str,\n) -> Result<Vec<u8>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT blob\n      FROM af_published_collab\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n        AND unpublished_at IS NULL\n        AND publish_name = $2\n    \"#,\n    publish_namespace,\n    publish_name,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_default_published_view_id_for_namespace<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  namespace: &str,\n) -> Result<Option<Uuid>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT default_published_view_id\n      FROM af_workspace\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n    \"#,\n    namespace,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_default_published_view_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Option<Uuid>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT default_published_view_id\n      FROM af_workspace\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res)\n}\n\nasync fn select_most_recent_non_original_namespace(\n  pg_pool: &PgPool,\n  namespace: &str,\n) -> Result<Option<String>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT namespace\n      FROM af_workspace_namespace\n      WHERE workspace_id = (SELECT workspace_id FROM af_workspace_namespace WHERE namespace = $1)\n        AND is_original = FALSE\n      ORDER BY created_at DESC\n      LIMIT 1\n    \"#,\n    namespace,\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_publish_info_for_view_ids(\n  pg_pool: &PgPool,\n  view_ids: &[Uuid],\n) -> Result<Vec<PublishInfo>, AppError> {\n  let mut res = sqlx::query_as!(\n    PublishInfo,\n    r#\"\n      SELECT\n        awn.namespace,\n        apc.publish_name,\n        apc.view_id,\n        au.email AS publisher_email,\n        apc.created_at AS publish_timestamp,\n        apc.unpublished_at AS unpublished_timestamp,\n        apc.comments_enabled,\n        apc.duplicate_enabled\n      FROM af_published_collab apc\n      JOIN af_user au ON apc.published_by = au.uid\n      JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\n      JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\n      WHERE apc.view_id = ANY($1);\n    \"#,\n    view_ids,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n\n  if res.is_empty() {\n    return Ok(res);\n  }\n  if let Some(non_original_namespace) =\n    select_most_recent_non_original_namespace(pg_pool, &res[0].namespace).await?\n  {\n    res.iter_mut().for_each(|info| {\n      info.namespace.clone_from(&non_original_namespace);\n    });\n  }\n  Ok(res)\n}\n\npub async fn select_published_collab_info(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n) -> Result<PublishInfo, AppError> {\n  select_publish_info_for_view_ids(pg_pool, &[*view_id])\n    .await?\n    .into_iter()\n    .next()\n    .ok_or(AppError::RecordNotFound(view_id.to_string()))\n}\n\npub async fn select_all_published_collab_info(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Vec<PublishInfo>, AppError> {\n  let mut res = sqlx::query_as!(\n    PublishInfo,\n    r#\"\n      SELECT\n        awn.namespace,\n        apc.publish_name,\n        apc.view_id,\n        au.email AS publisher_email,\n        apc.created_at AS publish_timestamp,\n        apc.unpublished_at AS unpublished_timestamp,\n        apc.comments_enabled,\n        apc.duplicate_enabled\n      FROM af_published_collab apc\n      JOIN af_user au ON apc.published_by = au.uid\n      JOIN af_workspace aw ON apc.workspace_id = aw.workspace_id\n      JOIN af_workspace_namespace awn ON aw.workspace_id = awn.workspace_id AND awn.is_original = TRUE\n      WHERE apc.workspace_id = $1 AND apc.unpublished_at IS NULL;\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n\n  use_non_orginal_namespace_if_possible(pg_pool, &mut res).await?;\n  Ok(res)\n}\n\nasync fn use_non_orginal_namespace_if_possible(\n  pg_pool: &PgPool,\n  publish_infos: &mut [PublishInfo],\n) -> Result<(), AppError> {\n  if publish_infos.is_empty() {\n    return Ok(());\n  }\n\n  if let Some(non_original_namespace) =\n    select_most_recent_non_original_namespace(pg_pool, &publish_infos[0].namespace).await?\n  {\n    publish_infos.iter_mut().for_each(|info| {\n      info.namespace.clone_from(&non_original_namespace);\n    });\n  }\n  Ok(())\n}\n\npub async fn select_workspace_id_for_publish_namespace<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  publish_namespace: &str,\n) -> Result<Uuid, AppError> {\n  let res = sqlx::query!(\n    r#\"\n      SELECT workspace_id\n      FROM af_workspace_namespace\n      WHERE namespace = $1\n    \"#,\n    publish_namespace,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res.workspace_id)\n}\n\npub async fn select_published_view_ids_for_workspace<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: Uuid,\n) -> Result<Vec<Uuid>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT view_id\n      FROM af_published_collab\n      WHERE workspace_id = $1\n      AND unpublished_at IS NULL\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_published_view_ids_with_publish_info_for_workspace<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: Uuid,\n) -> Result<Vec<AFPublishViewWithPublishInfo>, AppError> {\n  let res = sqlx::query_as!(\n    AFPublishViewWithPublishInfo,\n    r#\"\n      SELECT\n        apc.view_id,\n        apc.publish_name,\n        au.email AS publisher_email,\n        apc.created_at AS publish_timestamp,\n        apc.comments_enabled,\n        apc.duplicate_enabled\n      FROM af_published_collab apc\n      JOIN af_user au ON apc.published_by = au.uid\n      WHERE workspace_id = $1\n      AND unpublished_at IS NULL\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(res)\n}\n"
  },
  {
    "path": "libs/database/src/quick_note.rs",
    "content": "use app_error::AppError;\nuse database_entity::dto::QuickNote;\nuse sqlx::{Executor, Postgres, QueryBuilder};\nuse uuid::Uuid;\n\nuse crate::pg_row::AFQuickNoteRow;\n\npub async fn insert_new_quick_note<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: Uuid,\n  uid: i64,\n  data: &serde_json::Value,\n) -> Result<QuickNote, AppError> {\n  let quick_note = sqlx::query_as!(\n    QuickNote,\n    r#\"\n      INSERT INTO af_quick_note (workspace_id, uid, data) VALUES ($1, $2, $3)\n      RETURNING quick_note_id AS id, data, created_at AS \"created_at!\", updated_at AS \"last_updated_at!\"\n    \"#,\n    workspace_id,\n    uid,\n    data\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(quick_note)\n}\n\npub async fn select_quick_notes_with_one_more_than_limit<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: Uuid,\n  uid: i64,\n  search_term: Option<String>,\n  offset: Option<i32>,\n  limit: Option<i32>,\n) -> Result<Vec<QuickNote>, AppError> {\n  let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(\n    r#\"\n    SELECT\n      quick_note_id,\n      data,\n      created_at,\n      updated_at\n    FROM af_quick_note WHERE workspace_id =\n    \"#,\n  );\n  query_builder.push_bind(workspace_id);\n  query_builder.push(\" AND uid = \");\n  query_builder.push_bind(uid);\n  if let Some(search_term) = search_term.filter(|term| !term.is_empty()) {\n    query_builder.push(\" AND data @? \");\n    let json_path_query = format!(\"'$.**.insert ? (@ like_regex \\\".*{}.*\\\")'\", search_term);\n    query_builder.push(json_path_query);\n  }\n  query_builder.push(\" ORDER BY updated_at DESC\");\n  if let Some(limit) = limit {\n    query_builder.push(\" LIMIT \");\n    query_builder.push_bind(limit);\n    query_builder.push(\" + 1 \");\n  }\n  if let Some(offset) = offset {\n    query_builder.push(\" OFFSET \");\n    query_builder.push_bind(offset);\n  }\n  let query = query_builder.build_query_as::<AFQuickNoteRow>();\n  let quick_notes_with_one_more_than_limit = query\n    .fetch_all(executor)\n    .await?\n    .into_iter()\n    .map(Into::into)\n    .collect();\n  Ok(quick_notes_with_one_more_than_limit)\n}\n\npub async fn update_quick_note_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  quick_note_id: Uuid,\n  data: &serde_json::Value,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    \"UPDATE af_quick_note SET data = $1, updated_at = NOW() WHERE quick_note_id = $2\",\n    data,\n    quick_note_id\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn delete_quick_note_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  quick_note_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    \"DELETE FROM af_quick_note WHERE quick_note_id = $1\",\n    quick_note_id\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/database/src/resource_usage.rs",
    "content": "use crate::pg_row::AFBlobMetadataRow;\nuse app_error::AppError;\nuse rust_decimal::prelude::ToPrimitive;\nuse sqlx::types::Decimal;\nuse sqlx::{Executor, PgPool, Postgres, Transaction};\nuse std::ops::DerefMut;\n\nuse tracing::instrument;\nuse uuid::Uuid;\n\n#[instrument(level = \"trace\", skip_all)]\n#[inline]\npub async fn is_blob_metadata_exists(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n  file_id: &str,\n) -> Result<bool, AppError> {\n  let exists: (bool,) = sqlx::query_as(\n    r#\"\n     SELECT EXISTS (\n         SELECT 1\n         FROM af_blob_metadata\n         WHERE workspace_id = $1 AND file_id = $2\n     );\n    \"#,\n  )\n  .bind(workspace_id)\n  .bind(file_id)\n  .fetch_one(pool)\n  .await?;\n\n  Ok(exists.0)\n}\n\n#[instrument(level = \"trace\", skip_all, err)]\npub async fn insert_blob_metadata(\n  pg_pool: &PgPool,\n  file_id: &str,\n  workspace_id: &Uuid,\n  file_type: &str,\n  file_size: usize,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n        INSERT INTO af_blob_metadata\n        (workspace_id, file_id, file_type, file_size)\n        VALUES ($1, $2, $3, $4)\n        ON CONFLICT (workspace_id, file_id) DO UPDATE SET\n            file_type = $3,\n            file_size = $4\n        \"#,\n    workspace_id,\n    file_id,\n    file_type,\n    file_size as i64,\n  )\n  .execute(pg_pool)\n  .await?;\n  let n = res.rows_affected();\n  if n != 1 {\n    tracing::error!(\"insert_blob_metadata: rows_affected: {}\", n);\n  }\n  Ok(())\n}\n\n#[derive(Debug, Clone)]\npub struct BulkInsertMeta {\n  pub object_id: String,\n  pub file_id: String,\n  pub file_type: String,\n  pub file_size: i64,\n}\n\n#[instrument(level = \"trace\", skip_all, err)]\npub async fn insert_blob_metadata_bulk<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  metadata: Vec<BulkInsertMeta>,\n) -> Result<u64, sqlx::Error> {\n  let mut file_ids = Vec::with_capacity(metadata.len());\n  let mut file_types = Vec::with_capacity(metadata.len());\n  let mut file_sizes = Vec::with_capacity(metadata.len());\n\n  for BulkInsertMeta {\n    object_id,\n    file_id,\n    file_type,\n    file_size,\n  } in metadata\n  {\n    // we use BlobPathV1 to generate file_id\n    file_ids.push(format!(\"{}_{}\", object_id, file_id));\n    file_types.push(file_type);\n    file_sizes.push(file_size);\n  }\n  let query = r#\"\n        INSERT INTO af_blob_metadata (workspace_id, file_id, file_type, file_size)\n        SELECT $1, unnest($2::text[]), unnest($3::text[]), unnest($4::int8[])\n        ON CONFLICT DO NOTHING\n    \"#;\n\n  let result = sqlx::query(query)\n    .bind(workspace_id)\n    .bind(file_ids)\n    .bind(file_types)\n    .bind(file_sizes)\n    .execute(executor)\n    .await?;\n\n  Ok(result.rows_affected())\n}\n#[instrument(level = \"trace\", skip_all, err)]\n#[inline]\npub async fn delete_blob_metadata(\n  tx: &mut Transaction<'_, sqlx::Postgres>,\n  workspace_id: &Uuid,\n  file_id: &str,\n) -> Result<(), AppError> {\n  let result = sqlx::query!(\n    r#\"\n        DELETE FROM af_blob_metadata\n        WHERE workspace_id = $1 AND file_id = $2\n        \"#,\n    workspace_id,\n    file_id,\n  )\n  .execute(tx.deref_mut())\n  .await?;\n  let n = result.rows_affected();\n  tracing::info!(\"delete_blob_metadata: rows_affected: {}\", n);\n  Ok(())\n}\n\n#[instrument(level = \"trace\", skip_all, err)]\npub async fn get_blob_metadata(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  metadata_key: &str,\n) -> Result<AFBlobMetadataRow, AppError> {\n  tracing::trace!(\n    \"get_blob_metadata: workspace_id: {}, metadata_key: {}\",\n    workspace_id,\n    metadata_key\n  );\n  // file_id is the BlobPath's blob_metadata_key\n  let metadata = sqlx::query_as!(\n    AFBlobMetadataRow,\n    r#\"\n        SELECT * FROM af_blob_metadata\n        WHERE workspace_id = $1 AND file_id = $2\n        \"#,\n    workspace_id,\n    metadata_key,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n  Ok(metadata)\n}\n\n/// Return all blob metadata of a workspace\n#[instrument(level = \"trace\", skip_all, err)]\n#[inline]\npub async fn get_all_workspace_blob_metadata(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Vec<AFBlobMetadataRow>, AppError> {\n  let all_metadata = sqlx::query_as!(\n    AFBlobMetadataRow,\n    r#\"\n        SELECT * FROM af_blob_metadata\n        WHERE workspace_id = $1\n        \"#,\n    workspace_id,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(all_metadata)\n}\n\n/// Return all blob ids of a workspace\n#[instrument(level = \"trace\", skip_all, err)]\n#[inline]\npub async fn get_all_workspace_blob_ids(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Vec<String>, AppError> {\n  let file_ids = sqlx::query!(\n    r#\"\n    SELECT file_id FROM af_blob_metadata\n    WHERE workspace_id = $1\n    \"#,\n    workspace_id\n  )\n  .fetch_all(pg_pool)\n  .await?\n  .into_iter()\n  .map(|record| record.file_id)\n  .collect();\n  Ok(file_ids)\n}\n\n/// Return the total size of a workspace in bytes\n#[instrument(level = \"trace\", skip_all, err)]\n#[inline]\npub async fn get_workspace_usage_size(pool: &PgPool, workspace_id: &Uuid) -> Result<u64, AppError> {\n  let row: (Option<Decimal>,) =\n    sqlx::query_as(r#\"SELECT SUM(file_size) FROM af_blob_metadata WHERE workspace_id = $1;\"#)\n      .bind(workspace_id)\n      .fetch_one(pool)\n      .await?;\n  match row.0 {\n    Some(decimal) => Ok(decimal.to_u64().unwrap_or(0)),\n    None => Ok(0),\n  }\n}\n"
  },
  {
    "path": "libs/database/src/template.rs",
    "content": "use app_error::AppError;\nuse database_entity::dto::{\n  AccountLink, Template, TemplateCategory, TemplateCategoryType, TemplateCreator, TemplateGroup,\n  TemplateMinimal,\n};\nuse sqlx::{Executor, Postgres, QueryBuilder};\nuse uuid::Uuid;\n\nuse crate::pg_row::{\n  AFTemplateCategoryMinimalRow, AFTemplateCategoryRow, AFTemplateCategoryTypeColumn,\n  AFTemplateCreatorRow, AFTemplateGroupRow, AFTemplateMinimalRow, AFTemplateRow, AccountLinkColumn,\n};\n\npub async fn insert_new_template_category<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  name: &str,\n  description: &str,\n  icon: &str,\n  bg_color: &str,\n  category_type: TemplateCategoryType,\n  priority: i32,\n) -> Result<TemplateCategory, AppError> {\n  let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();\n  let new_template_category = sqlx::query_as!(\n    TemplateCategory,\n    r#\"\n    INSERT INTO af_template_category (name, description, icon, bg_color, category_type, priority)\n    VALUES ($1, $2, $3, $4, $5, $6)\n    RETURNING\n      category_id AS id,\n      name,\n      description,\n      icon,\n      bg_color,\n      category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n      priority\n    \"#,\n    name,\n    description,\n    icon,\n    bg_color,\n    category_type_column as AFTemplateCategoryTypeColumn,\n    priority,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(new_template_category)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  id: Uuid,\n  name: &str,\n  description: &str,\n  icon: &str,\n  bg_color: &str,\n  category_type: TemplateCategoryType,\n  priority: i32,\n) -> Result<TemplateCategory, AppError> {\n  let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();\n  let new_template_category = sqlx::query_as!(\n    TemplateCategory,\n    r#\"\n    UPDATE af_template_category\n    SET\n      name = $2,\n      description = $3,\n      icon = $4,\n      bg_color = $5,\n      category_type = $6,\n      priority = $7,\n      updated_at = NOW()\n    WHERE category_id = $1\n    RETURNING\n      category_id AS id,\n      name,\n      description,\n      icon,\n      bg_color,\n      category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n      priority\n    \"#,\n    id,\n    name,\n    description,\n    icon,\n    bg_color,\n    category_type_column as AFTemplateCategoryTypeColumn,\n    priority,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(new_template_category)\n}\n\npub async fn select_template_categories<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  name_contains: Option<&str>,\n  category_type: Option<TemplateCategoryType>,\n) -> Result<Vec<TemplateCategory>, AppError> {\n  let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(\n    r#\"\n    SELECT\n      category_id,\n      name,\n      description,\n      icon,\n      bg_color,\n      category_type,\n      priority\n    FROM af_template_category\n    WHERE TRUE\n    \"#,\n  );\n  if let Some(category_type) = category_type {\n    let category_type_column: AFTemplateCategoryTypeColumn = category_type.into();\n    query_builder.push(\" AND category_type = \");\n    query_builder.push_bind(category_type_column);\n  };\n  if let Some(name_contains) = name_contains {\n    query_builder.push(\" AND name ILIKE CONCAT('%', \");\n    query_builder.push_bind(name_contains);\n    query_builder.push(\" , '%')\");\n  };\n  query_builder.push(\" ORDER BY priority DESC, created_at ASC\");\n  let query = query_builder.build_query_as::<AFTemplateCategoryRow>();\n\n  let category_rows: Vec<AFTemplateCategoryRow> = query.fetch_all(executor).await?;\n  let categories = category_rows.into_iter().map(|row| row.into()).collect();\n\n  Ok(categories)\n}\n\npub async fn select_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  category_id: Uuid,\n) -> Result<TemplateCategory, AppError> {\n  let category = sqlx::query_as!(\n    TemplateCategory,\n    r#\"\n    SELECT\n      category_id AS id,\n      name,\n      description,\n      icon,\n      bg_color,\n      category_type AS \"category_type: AFTemplateCategoryTypeColumn\",\n      priority\n    FROM af_template_category\n    WHERE category_id = $1\n    \"#,\n    category_id,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(category)\n}\n\npub async fn delete_template_category_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  category_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_template_category\n    WHERE category_id = $1\n    \"#,\n    category_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn insert_template_creator<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  name: &str,\n  avatar_url: &str,\n  account_links: &[AccountLink],\n) -> Result<TemplateCreator, AppError> {\n  let link_types: Vec<String> = account_links\n    .iter()\n    .map(|link| link.link_type.clone())\n    .collect();\n  let url: Vec<String> = account_links.iter().map(|link| link.url.clone()).collect();\n  let new_template_creator_row = sqlx::query_as!(\n    AFTemplateCreatorRow,\n    r#\"\n    WITH\n      new_creator AS (\n        INSERT INTO af_template_creator (name, avatar_url)\n        VALUES ($1, $2)\n        RETURNING creator_id, name, avatar_url\n      ),\n      account_links AS (\n        INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n        SELECT new_creator.creator_id as creator_id, link_type, url FROM\n        UNNEST($3::text[], $4::text[]) AS t(link_type, url)\n        CROSS JOIN new_creator\n        RETURNING\n          creator_id,\n          link_type,\n          url\n      )\n    SELECT\n      new_creator.creator_id AS id,\n      name,\n      avatar_url,\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\",\n      0 AS \"number_of_templates!\"\n      FROM new_creator\n      LEFT OUTER JOIN account_links\n      USING (creator_id)\n      GROUP BY (id, name, avatar_url)\n    \"#,\n    name,\n    avatar_url,\n    link_types.as_slice(),\n    url.as_slice(),\n  )\n  .fetch_one(executor)\n  .await?;\n  let new_template_creator = new_template_creator_row.into();\n  Ok(new_template_creator)\n}\n\npub async fn update_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  creator_id: Uuid,\n  name: &str,\n  avatar_url: &str,\n  account_links: &[AccountLink],\n) -> Result<TemplateCreator, AppError> {\n  let link_types: Vec<String> = account_links\n    .iter()\n    .map(|link| link.link_type.clone())\n    .collect();\n  let url: Vec<String> = account_links.iter().map(|link| link.url.clone()).collect();\n  let updated_template_creator_row = sqlx::query_as!(\n    AFTemplateCreatorRow,\n    r#\"\n    WITH\n      updated_creator AS (\n        UPDATE af_template_creator\n          SET name = $2, avatar_url = $3, updated_at = NOW()\n          WHERE creator_id = $1\n        RETURNING creator_id, name, avatar_url\n      ),\n      account_links AS (\n        INSERT INTO af_template_creator_account_link (creator_id, link_type, url)\n        SELECT updated_creator.creator_id as creator_id, link_type, url FROM\n        UNNEST($4::text[], $5::text[]) AS t(link_type, url)\n        CROSS JOIN updated_creator\n        RETURNING\n          creator_id,\n          link_type,\n          url\n      ),\n      creator_number_of_templates AS (\n        SELECT\n          creator_id,\n          COUNT(1)::int AS number_of_templates\n        FROM af_template_view\n        WHERE creator_id = $1\n        GROUP BY creator_id\n      )\n    SELECT\n      updated_creator.creator_id AS id,\n      name,\n      avatar_url,\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\",\n      COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n      FROM updated_creator\n      LEFT OUTER JOIN account_links\n      USING (creator_id)\n      LEFT OUTER JOIN creator_number_of_templates\n      USING (creator_id)\n      GROUP BY (id, name, avatar_url, number_of_templates)\n    \"#,\n    creator_id,\n    name,\n    avatar_url,\n    link_types.as_slice(),\n    url.as_slice(),\n  )\n  .fetch_one(executor)\n  .await?;\n  let updated_template_creator = updated_template_creator_row.into();\n  Ok(updated_template_creator)\n}\n\npub async fn delete_template_creator_account_links<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  creator_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_template_creator_account_link\n    WHERE creator_id = $1\n    \"#,\n    creator_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn select_template_creators_by_name<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  substr_match: &str,\n) -> Result<Vec<TemplateCreator>, AppError> {\n  let creator_rows = sqlx::query_as!(\n    AFTemplateCreatorRow,\n    r#\"\n    WITH creator_number_of_templates AS (\n      SELECT\n        creator_id,\n        COUNT(1)::int AS number_of_templates\n      FROM af_template_view\n      WHERE name ILIKE $1\n      GROUP BY creator_id\n    )\n\n    SELECT\n      creator.creator_id AS \"id!\",\n      name AS \"name!\",\n      avatar_url AS \"avatar_url!\",\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\",\n      COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n    FROM af_template_creator creator\n    LEFT OUTER JOIN af_template_creator_account_link account_link\n    USING (creator_id)\n    LEFT OUTER JOIN creator_number_of_templates\n    USING (creator_id)\n    WHERE name ILIKE $1\n    GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\n    ORDER BY created_at ASC\n    \"#,\n    format!(\"%{}%\", substr_match)\n  )\n  .fetch_all(executor)\n  .await?;\n  let creators = creator_rows.into_iter().map(|row| row.into()).collect();\n  Ok(creators)\n}\n\npub async fn select_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  creator_id: Uuid,\n) -> Result<TemplateCreator, AppError> {\n  let creator_row = sqlx::query_as!(\n    AFTemplateCreatorRow,\n    r#\"\n    WITH creator_number_of_templates AS (\n      SELECT\n        creator_id,\n        COUNT(1)::int AS number_of_templates\n      FROM af_template_view\n      WHERE creator_id = $1\n      GROUP BY creator_id\n    )\n    SELECT\n      creator.creator_id AS \"id!\",\n      name AS \"name!\",\n      avatar_url AS \"avatar_url!\",\n      ARRAY_AGG((link_type, url)) FILTER (WHERE link_type IS NOT NULL) AS \"account_links: Vec<AccountLinkColumn>\",\n      COALESCE(number_of_templates, 0) AS \"number_of_templates!\"\n    FROM af_template_creator creator\n    LEFT OUTER JOIN af_template_creator_account_link account_link\n    USING (creator_id)\n    LEFT OUTER JOIN creator_number_of_templates\n    USING (creator_id)\n    WHERE creator.creator_id = $1\n    GROUP BY (creator.creator_id, name, avatar_url, number_of_templates)\n    \"#,\n    creator_id\n  )\n  .fetch_one(executor)\n  .await?;\n  let creator = creator_row.into();\n  Ok(creator)\n}\n\npub async fn delete_template_creator_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  creator_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_template_creator\n    WHERE creator_id = $1\n    \"#,\n    creator_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn insert_template_view_template_category<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n  category_ids: &[Uuid],\n) -> Result<(), AppError> {\n  let rows_affected = sqlx::query!(\n    r#\"\n    INSERT INTO af_template_view_template_category (view_id, category_id)\n    SELECT $1 as view_id, category_id FROM\n    UNNEST($2::uuid[]) AS category_id\n    \"#,\n    view_id,\n    category_ids\n  )\n  .execute(executor)\n  .await?\n  .rows_affected();\n  if rows_affected == 0 {\n    tracing::error!(\n      \"at least one category id is expected to be inserted for view_id {}\",\n      view_id\n    );\n  }\n  Ok(())\n}\n\npub async fn delete_template_view_template_categories<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_template_view_template_category\n    WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn insert_related_templates<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n  category_ids: &[Uuid],\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    INSERT INTO af_related_template_view (view_id, related_view_id)\n    SELECT $1 AS view_id, related_view_id\n    FROM UNNEST($2::uuid[]) AS t(related_view_id)\n    \"#,\n    view_id,\n    category_ids\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn delete_related_templates<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_related_template_view\n    WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn insert_template_view<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n  name: &str,\n  description: &str,\n  about: &str,\n  view_url: &str,\n  creator_id: Uuid,\n  is_new_template: bool,\n  is_featured: bool,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    INSERT INTO af_template_view (\n      view_id,\n      name,\n      description,\n      about,\n      view_url,\n      creator_id,\n      is_new_template,\n      is_featured\n    )\n    VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n    \"#,\n    view_id,\n    name,\n    description,\n    about,\n    view_url,\n    creator_id,\n    is_new_template,\n    is_featured\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_template_view<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n  name: &str,\n  description: &str,\n  about: &str,\n  view_url: &str,\n  creator_id: Uuid,\n  is_new_template: bool,\n  is_featured: bool,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    UPDATE af_template_view SET\n      updated_at = NOW(),\n      name = $2,\n      description = $3,\n      about = $4,\n      view_url = $5,\n      creator_id = $6,\n      is_new_template = $7,\n      is_featured = $8\n      WHERE view_id = $1\n    \"#,\n    view_id,\n    name,\n    description,\n    about,\n    view_url,\n    creator_id,\n    is_new_template,\n    is_featured\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\npub async fn select_template_view_by_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n) -> Result<Template, AppError> {\n  let view_row = sqlx::query_as!(\n    AFTemplateRow,\n    r#\"\n      WITH template_with_creator_account_link AS (\n        SELECT\n          template.view_id,\n          template.creator_id,\n          COALESCE(\n            ARRAY_AGG((link_type, url)::account_link_type) FILTER (WHERE link_type IS NOT NULL),\n            '{}'\n          ) AS account_links\n        FROM af_template_view template\n        JOIN af_published_collab\n        USING (view_id)\n        JOIN af_template_creator creator\n        USING (creator_id)\n        LEFT OUTER JOIN af_template_creator_account_link account_link\n        USING (creator_id)\n        WHERE view_id = $1\n        GROUP BY (view_id, template.creator_id)\n      ),\n      related_template_with_category AS (\n        SELECT\n          template.related_view_id,\n          ARRAY_AGG(\n            (\n              template_category.category_id,\n              template_category.name,\n              template_category.icon,\n              template_category.bg_color\n            )::template_category_minimal_type\n          ) AS categories\n        FROM af_related_template_view template\n        JOIN af_template_view_template_category template_template_category\n        ON template.related_view_id = template_template_category.view_id\n        JOIN af_template_category template_category\n        USING (category_id)\n        WHERE template.view_id = $1\n        GROUP BY template.related_view_id\n      ),\n      template_with_related_template AS (\n        SELECT\n          template.view_id,\n          ARRAY_AGG(\n            (\n              template.related_view_id,\n              related_template.created_at,\n              related_template.updated_at,\n              related_template.name,\n              related_template.description,\n              related_template.view_url,\n              (\n                creator.creator_id,\n                creator.name,\n                creator.avatar_url\n              )::template_creator_minimal_type,\n              related_template_with_category.categories,\n              related_template.is_new_template,\n              related_template.is_featured\n            )::template_minimal_type\n          ) AS related_templates\n        FROM af_related_template_view template\n        JOIN af_template_view related_template\n        ON template.related_view_id = related_template.view_id\n        JOIN af_template_creator creator\n        ON related_template.creator_id = creator.creator_id\n        JOIN related_template_with_category\n        ON template.related_view_id = related_template_with_category.related_view_id\n        WHERE template.view_id = $1\n        GROUP BY template.view_id\n      ),\n      template_with_category AS (\n        SELECT\n          view_id,\n          COALESCE(\n            ARRAY_AGG((\n              vtc.category_id,\n              name,\n              icon,\n              bg_color,\n              description,\n              category_type,\n              priority\n            )) FILTER (WHERE vtc.category_id IS NOT NULL),\n            '{}'\n          ) AS categories\n        FROM af_template_view_template_category vtc\n        JOIN af_template_category tc\n        ON vtc.category_id = tc.category_id\n        WHERE view_id = $1\n        GROUP BY view_id\n      ),\n      creator_number_of_templates AS (\n        SELECT\n          creator_id,\n          COUNT(*) AS number_of_templates\n        FROM af_template_view\n        GROUP BY creator_id\n      )\n\n      SELECT\n        template.view_id,\n        template.created_at,\n        template.updated_at,\n        template.name,\n        template.description,\n        template.about,\n        template.view_url,\n        (\n          creator.creator_id,\n          creator.name,\n          creator.avatar_url,\n          template_with_creator_account_link.account_links,\n          creator_number_of_templates.number_of_templates\n        )::template_creator_type AS \"creator!: AFTemplateCreatorRow\",\n        template_with_category.categories AS \"categories!: Vec<AFTemplateCategoryRow>\",\n        COALESCE(template_with_related_template.related_templates, '{}') AS \"related_templates!: Vec<AFTemplateMinimalRow>\",\n        template.is_new_template,\n        template.is_featured\n      FROM af_template_view template\n      JOIN af_template_creator creator\n      USING (creator_id)\n      JOIN template_with_creator_account_link\n      ON template.view_id = template_with_creator_account_link.view_id\n      LEFT OUTER JOIN template_with_related_template\n      ON template.view_id = template_with_related_template.view_id\n      JOIN template_with_category\n      ON template.view_id = template_with_category.view_id\n      LEFT OUTER JOIN creator_number_of_templates\n      ON template.creator_id = creator_number_of_templates.creator_id\n      WHERE template.view_id = $1\n\n    \"#,\n    view_id\n  )\n  .fetch_one(executor)\n  .await?;\n  let view = view_row.into();\n  Ok(view)\n}\n\npub async fn select_templates<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  category_id: Option<Uuid>,\n  is_featured: Option<bool>,\n  is_new_template: Option<bool>,\n  name_contains: Option<&str>,\n  limit: Option<i64>,\n) -> Result<Vec<TemplateMinimal>, AppError> {\n  let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(\n    r#\"\n    WITH template_with_template_category AS (\n      SELECT\n        template_template_category.view_id,\n        ARRAY_AGG((\n          template_template_category.category_id,\n          category.name,\n          category.icon,\n          category.bg_color\n        )::template_category_minimal_type) AS categories\n      FROM af_template_view_template_category template_template_category\n      JOIN af_template_category category\n      USING (category_id)\n      JOIN af_template_view template\n      USING (view_id)\n      JOIN af_published_collab\n      USING (view_id)\n      WHERE TRUE\n    \"#,\n  );\n  if let Some(category_id) = category_id {\n    query_builder.push(\" AND template_template_category.category_id = \");\n    query_builder.push_bind(category_id);\n  };\n  if let Some(is_featured) = is_featured {\n    query_builder.push(\" AND template.is_featured = \");\n    query_builder.push_bind(is_featured);\n  };\n  if let Some(is_new_template) = is_new_template {\n    query_builder.push(\" AND template.is_new_template = \");\n    query_builder.push_bind(is_new_template);\n  };\n  if let Some(name_contains) = name_contains {\n    query_builder.push(\" AND template.name ILIKE CONCAT('%', \");\n    query_builder.push_bind(name_contains);\n    query_builder.push(\" , '%')\");\n  };\n  query_builder.push(\n    r#\"\n      GROUP BY template_template_category.view_id\n    )\n\n    SELECT\n      template.view_id,\n      template.created_at,\n      template.updated_at,\n      template.name,\n      template.description,\n      template.view_url,\n      (\n        template_creator.creator_id,\n        template_creator.name,\n        template_creator.avatar_url\n      )::template_creator_minimal_type AS creator,\n      tc.categories AS categories,\n      template.is_new_template,\n      template.is_featured\n    FROM template_with_template_category tc\n    JOIN af_template_view template\n    USING (view_id)\n    JOIN af_template_creator template_creator\n    USING (creator_id)\n    ORDER BY template.created_at DESC\n    \"#,\n  );\n  if let Some(limit) = limit {\n    query_builder.push(\" LIMIT \");\n    query_builder.push_bind(limit);\n  };\n  let query = query_builder.build_query_as::<AFTemplateMinimalRow>();\n  let template_rows: Vec<AFTemplateMinimalRow> = query.fetch_all(executor).await?;\n  Ok(template_rows.into_iter().map(|row| row.into()).collect())\n}\n\npub async fn select_template_homepage<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  per_count: i64,\n) -> Result<Vec<TemplateGroup>, AppError> {\n  let template_group_rows = sqlx::query_as!(\n    AFTemplateGroupRow,\n    r#\"\n      WITH recent_template AS (\n        SELECT\n          template_template_category.category_id,\n          template_template_category.view_id,\n          category.name,\n          category.icon,\n          category.bg_color,\n          ROW_NUMBER() OVER (PARTITION BY template_template_category.category_id ORDER BY template.created_at DESC) AS recency\n        FROM af_template_view_template_category template_template_category\n        JOIN af_template_category category\n        USING (category_id)\n        JOIN af_template_view template\n        USING (view_id)\n        JOIN af_published_collab\n        USING (view_id)\n      ),\n      template_group_by_category_and_view AS (\n        SELECT\n          category_id,\n          view_id,\n          ARRAY_AGG((\n            category_id,\n            name,\n            icon,\n            bg_color\n          )::template_category_minimal_type) AS categories\n          FROM recent_template\n          WHERE recency <= $1\n          GROUP BY category_id, view_id\n      ),\n      template_group_by_category_and_view_with_creator_and_template_details AS (\n        SELECT\n          template_group_by_category_and_view.category_id,\n          (\n            template.view_id,\n            template.created_at,\n            template.updated_at,\n            template.name,\n            template.description,\n            template.view_url,\n            (\n              creator.creator_id,\n              creator.name,\n              creator.avatar_url\n            )::template_creator_minimal_type,\n            template_group_by_category_and_view.categories,\n            template.is_new_template,\n            template.is_featured\n          )::template_minimal_type AS template\n        FROM template_group_by_category_and_view\n        JOIN af_template_view template\n        USING (view_id)\n        JOIN af_template_creator creator\n        USING (creator_id)\n      ),\n      template_group_by_category AS (\n        SELECT\n          category_id,\n          ARRAY_AGG(template) AS templates\n        FROM template_group_by_category_and_view_with_creator_and_template_details\n        GROUP BY category_id\n      )\n      SELECT\n        (\n          template_group_by_category.category_id,\n          category.name,\n          category.icon,\n          category.bg_color\n        )::template_category_minimal_type AS \"category!: AFTemplateCategoryMinimalRow\",\n        templates AS \"templates!: Vec<AFTemplateMinimalRow>\"\n        FROM template_group_by_category\n        JOIN af_template_category category\n        USING (category_id)\n    \"#,\n    per_count,\n  )\n  .fetch_all(executor)\n  .await?;\n  Ok(\n    template_group_rows\n      .into_iter()\n      .map(|row| row.into())\n      .collect(),\n  )\n}\n\npub async fn delete_template_by_view_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n    DELETE FROM af_template_view\n    WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/database/src/user.rs",
    "content": "use database_entity::dto::{AFUserWithAvatar, AFWebUser};\nuse futures_util::stream::BoxStream;\nuse sqlx::postgres::PgArguments;\nuse sqlx::types::JsonValue;\nuse sqlx::{Arguments, Executor, PgPool, Postgres};\nuse tracing::{instrument, warn};\nuse uuid::Uuid;\n\nuse app_error::AppError;\n\nuse crate::pg_row::AFUserIdRow;\n\n/// Updates the user's details in the `af_user` table.\n///\n/// This function allows for updating the user's name, email, and metadata based on the provided UUID.\n/// If the `metadata` is provided, it merges the new metadata with the existing one, with the new values\n/// overriding the old ones in case of conflicts.\n///\n/// # Arguments\n///\n/// * `pool` - A reference to the database connection pool.\n/// * `user_uuid` - The UUID of the user to be updated.\n/// * `name` - An optional new name for the user.\n/// * `email` - An optional new email for the user.\n/// * `metadata` - An optional JSON value containing new metadata for the user.\n///\n#[instrument(skip_all, err)]\n#[inline]\npub async fn update_user(\n  pool: &PgPool,\n  user_uuid: &uuid::Uuid,\n  name: Option<String>,\n  email: Option<String>,\n  metadata: Option<JsonValue>,\n) -> Result<(), AppError> {\n  let mut set_clauses = Vec::new();\n  let mut args = PgArguments::default();\n  let mut args_num = 0;\n\n  if let Some(n) = name {\n    args_num += 1;\n    set_clauses.push(format!(\"name = ${}\", args_num));\n    args.add(n).map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode user name for user {}\", user_uuid),\n      err,\n    })?;\n  }\n\n  if let Some(e) = email {\n    args_num += 1;\n    set_clauses.push(format!(\"email = ${}\", args_num));\n    args.add(e).map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode email for user {}\", user_uuid),\n      err,\n    })?;\n  }\n\n  if let Some(m) = metadata {\n    args_num += 1;\n    // Merge existing metadata with new metadata\n    set_clauses.push(format!(\"metadata = metadata || ${}\", args_num));\n    args.add(m).map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode metadata for user {}\", user_uuid),\n      err,\n    })?;\n  }\n\n  if set_clauses.is_empty() {\n    warn!(\"No update params provided\");\n    return Ok(());\n  }\n\n  // where\n  args_num += 1;\n  let query = format!(\n    \"UPDATE af_user SET {} WHERE uuid = ${}\",\n    set_clauses.join(\", \"),\n    args_num\n  );\n  args\n    .add(user_uuid)\n    .map_err(|err| AppError::SqlxArgEncodingError {\n      desc: format!(\"unable to encode user uuid {}\", user_uuid),\n      err,\n    })?;\n\n  sqlx::query_with(&query, args).execute(pool).await?;\n  Ok(())\n}\n\n/// Attempts to create a new user in the database if they do not already exist.\n///\n/// This function will:\n/// - Insert a new user record into the `af_user` table if the email is unique.\n/// - If the user is newly created, it will also:\n///   - Create a new workspace for the user in the `af_workspace` table.\n///   - Assign the user a role in the `af_workspace_member` table.\n///   - Add the user to the `af_collab_member` table with the appropriate permissions.\n///\n/// # Returns\n/// A `Result` containing the workspace_id of the user's newly created workspace\n#[instrument(skip(executor), err)]\n#[inline]\npub async fn create_user<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  uid: i64,\n  user_uuid: &Uuid,\n  email: &str,\n  name: &str,\n) -> Result<Uuid, AppError> {\n  let name = {\n    if name.is_empty() {\n      email\n    } else {\n      name\n    }\n  };\n\n  let row = sqlx::query!(\n    r#\"\n    WITH ins_user AS (\n        INSERT INTO af_user (uid, uuid, email, name)\n        VALUES ($1, $2, $3, $4)\n        RETURNING uid\n    ),\n    owner_role AS (\n        SELECT id FROM af_roles WHERE name = 'Owner'\n    ),\n    ins_workspace AS (\n        INSERT INTO af_workspace (owner_uid)\n        SELECT uid FROM ins_user\n        RETURNING workspace_id, owner_uid\n    )\n    SELECT workspace_id FROM ins_workspace;\n    \"#,\n    uid,\n    user_uuid,\n    email,\n    name\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(row.workspace_id)\n}\n\n#[inline]\n#[instrument(level = \"trace\", skip(executor), err)]\npub async fn select_uuid_from_uid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uid: i64,\n) -> Result<Uuid, AppError> {\n  let uuid = sqlx::query_scalar!(\n    r#\"\n      SELECT uuid FROM af_user WHERE uid = $1\n    \"#,\n    user_uid\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(uuid)\n}\n\n#[inline]\n#[instrument(level = \"trace\", skip(executor), err)]\npub async fn select_uid_from_uuid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<i64, AppError> {\n  let uid = sqlx::query!(\n    r#\"\n      SELECT uid FROM af_user WHERE uuid = $1\n    \"#,\n    user_uuid\n  )\n  .fetch_one(executor)\n  .await?\n  .uid;\n  Ok(uid)\n}\n\npub fn select_all_uid_uuid<'a, E: Executor<'a, Database = Postgres> + 'a>(\n  executor: E,\n) -> BoxStream<'a, sqlx::Result<AFUserIdRow>> {\n  sqlx::query_as!(AFUserIdRow, r#\" SELECT uid, uuid FROM af_user\"#,).fetch(executor)\n}\n\n#[inline]\npub async fn select_uid_from_email<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  email: &str,\n) -> Result<i64, AppError> {\n  let uid = sqlx::query!(\n    r#\"\n      SELECT uid FROM af_user WHERE email = $1\n    \"#,\n    email\n  )\n  .fetch_one(executor)\n  .await?\n  .uid;\n  Ok(uid)\n}\n\n#[inline]\npub async fn is_user_exist<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<bool, AppError> {\n  let exists = sqlx::query_scalar!(\n    r#\"\n    SELECT EXISTS(\n      SELECT 1\n      FROM af_user\n      WHERE uuid = $1\n    ) AS user_exists;\n  \"#,\n    user_uuid\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(exists.unwrap_or(false))\n}\n\n#[inline]\npub async fn select_email_from_user_uuid(\n  pool: &PgPool,\n  user_uuid: &Uuid,\n) -> Result<String, AppError> {\n  let email = sqlx::query_scalar!(\n    r#\"\n      SELECT email FROM af_user WHERE uuid = $1\n    \"#,\n    user_uuid\n  )\n  .fetch_one(pool)\n  .await?;\n  Ok(email)\n}\n\n#[inline]\npub async fn select_email_from_user_uid(pool: &PgPool, user_uid: i64) -> Result<String, AppError> {\n  let email = sqlx::query_scalar!(\n    r#\"\n      SELECT email FROM af_user WHERE uid = $1\n    \"#,\n    user_uid\n  )\n  .fetch_one(pool)\n  .await?;\n  Ok(email)\n}\n\n#[inline]\npub async fn select_name_from_uuid(pool: &PgPool, user_uuid: &Uuid) -> Result<String, AppError> {\n  let email = sqlx::query_scalar!(\n    r#\"\n      SELECT name FROM af_user WHERE uuid = $1\n    \"#,\n    user_uuid\n  )\n  .fetch_one(pool)\n  .await?;\n  Ok(email)\n}\n\npub async fn select_name_and_email_from_uuid(\n  pool: &PgPool,\n  user_uuid: &Uuid,\n) -> Result<(String, String), AppError> {\n  let row = sqlx::query!(\n    r#\"\n    SELECT name, email FROM af_user WHERE uuid = $1\n    \"#,\n    user_uuid\n  )\n  .fetch_one(pool)\n  .await?;\n\n  Ok((row.name, row.email))\n}\n\npub async fn select_web_user_from_uid(\n  pool: &PgPool,\n  uid: i64,\n) -> Result<Option<AFWebUser>, AppError> {\n  let row = sqlx::query_as!(\n    AFWebUser,\n    r#\"\n    SELECT\n      uuid,\n      name,\n      metadata ->> 'icon_url' AS avatar_url\n    FROM af_user\n    WHERE uid = $1\n    \"#,\n    uid\n  )\n  .fetch_optional(pool)\n  .await\n  .map_err(|err| anyhow::anyhow!(\"Unable to get user detail for {}: {}\", uid, err))?;\n\n  Ok(row)\n}\n\npub async fn select_user_with_avatar<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  uid: i64,\n) -> Result<AFUserWithAvatar, AppError> {\n  let user = sqlx::query_as!(\n    AFUserWithAvatar,\n    r#\"\n      SELECT\n        uuid,\n        email,\n        name,\n        metadata ->> 'icon_url' AS avatar_url\n      FROM af_user\n      WHERE uid = $1;\n    \"#,\n    uid\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(user)\n}\n"
  },
  {
    "path": "libs/database/src/workspace.rs",
    "content": "use chrono::{DateTime, Utc};\nuse database_entity::dto::{\n  AFRole, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings, GlobalComment,\n  InvitationCodeInfo, MentionableWorkspaceMemberOrGuest,\n  MentionableWorkspaceMemberOrGuestWithLastMentionedTime, PageMentionUpdate, Reaction,\n  WorkspaceMemberProfile,\n};\nuse futures_util::stream::BoxStream;\nuse sqlx::{types::uuid, Executor, PgPool, Postgres, Transaction};\nuse std::{collections::HashMap, ops::DerefMut};\nuse tracing::{event, instrument};\nuse uuid::Uuid;\n\nuse crate::pg_row::{\n  AFGlobalCommentRow, AFImportTask, AFPermissionRow, AFReactionRow, AFUserProfileRow,\n  AFWebUserWithEmailColumn, AFWorkspaceInvitationMinimal, AFWorkspaceMemberPermRow,\n  AFWorkspaceMemberRow, AFWorkspaceRow, AFWorkspaceRowWithMemberCountAndRole,\n};\nuse crate::user::select_uid_from_email;\nuse app_error::AppError;\n\n#[inline]\npub async fn delete_from_workspace(pg_pool: &PgPool, workspace_id: &Uuid) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      DELETE FROM public.af_workspace\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id\n  )\n  .execute(pg_pool)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to delete workspace, workspace_id: {}, rows_affected: {}\",\n      workspace_id,\n      res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\n#[inline]\npub async fn insert_user_workspace(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  workspace_name: &str,\n  workspace_icon: &str,\n  is_initialized: bool,\n) -> Result<AFWorkspaceRow, AppError> {\n  let workspace = sqlx::query_as!(\n    AFWorkspaceRow,\n    r#\"\n    WITH new_workspace AS (\n      INSERT INTO public.af_workspace (owner_uid, workspace_name, icon, is_initialized)\n      VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2, $3, $4)\n      RETURNING *\n    )\n    SELECT\n      workspace_id,\n      database_storage_id,\n      owner_uid,\n      owner_profile.name AS owner_name,\n      owner_profile.email AS owner_email,\n      new_workspace.created_at,\n      workspace_type,\n      new_workspace.deleted_at,\n      workspace_name,\n      icon\n    FROM new_workspace\n    JOIN public.af_user AS owner_profile ON new_workspace.owner_uid = owner_profile.uid;\n    \"#,\n    user_uuid,\n    workspace_name,\n    workspace_icon,\n    is_initialized,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(workspace)\n}\n\n#[inline]\npub async fn rename_workspace(\n  tx: &mut Transaction<'_, sqlx::Postgres>,\n  workspace_id: &Uuid,\n  new_workspace_name: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE public.af_workspace\n      SET workspace_name = $1\n      WHERE workspace_id = $2\n    \"#,\n    new_workspace_name,\n    workspace_id,\n  )\n  .execute(tx.deref_mut())\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\"Failed to rename workspace, workspace_id: {}\", workspace_id);\n  }\n  Ok(())\n}\n\n#[inline]\npub async fn change_workspace_icon(\n  tx: &mut Transaction<'_, sqlx::Postgres>,\n  workspace_id: &Uuid,\n  icon: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE public.af_workspace\n      SET icon = $1\n      WHERE workspace_id = $2\n    \"#,\n    icon,\n    workspace_id,\n  )\n  .execute(tx.deref_mut())\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to change workspace icon, workspace_id: {}\",\n      workspace_id\n    );\n  }\n  Ok(())\n}\n\n/// Checks whether a user, identified by a UUID, is an 'Owner' of a workspace, identified by its\n/// workspace_id.\n#[inline]\npub async fn select_user_is_workspace_owner(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  workspace_uuid: &Uuid,\n) -> Result<bool, AppError> {\n  let exists = sqlx::query_scalar!(\n    r#\"\n  SELECT EXISTS(\n    SELECT 1\n    FROM public.af_workspace_member\n      JOIN af_roles ON af_workspace_member.role_id = af_roles.id\n    WHERE workspace_id = $1\n    AND af_workspace_member.uid = (\n      SELECT uid FROM public.af_user WHERE uuid = $2\n    )\n    AND af_roles.name = 'Owner'\n  ) AS \"exists\";\n  \"#,\n    workspace_uuid,\n    user_uuid\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(exists.unwrap_or(false))\n}\n\npub async fn select_user_is_allowed_to_delete_comment(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  view_id: &Uuid,\n  comment_id: &Uuid,\n) -> Result<bool, AppError> {\n  let is_publisher_for_view = sqlx::query_scalar!(\n    r#\"\n    SELECT EXISTS(\n      SELECT true\n      FROM af_published_collab\n      WHERE view_id = $1\n        AND published_by = (SELECT uid FROM af_user WHERE uuid = $2)\n      UNION ALL\n      SELECT true\n      FROM af_published_view_comment\n      WHERE view_id = $1\n        AND comment_id = $3\n        AND created_by = (SELECT uid FROM af_user WHERE uuid = $2)\n    ) AS \"exists\";\n    \"#,\n    view_id,\n    user_uuid,\n    comment_id,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(is_publisher_for_view.unwrap_or(false))\n}\n\n#[inline]\npub async fn select_user_role<'a, E: Executor<'a, Database = Postgres>>(\n  exectuor: E,\n  uid: &i64,\n  workspace_uuid: &Uuid,\n) -> Result<AFRole, AppError> {\n  let row = sqlx::query_scalar!(\n    r#\"\n     SELECT role_id FROM af_workspace_member\n     WHERE workspace_id = $1 AND uid = $2\n    \"#,\n    workspace_uuid,\n    uid\n  )\n  .fetch_one(exectuor)\n  .await?;\n  Ok(AFRole::from(row))\n}\n\n#[inline]\npub async fn upsert_workspace_member_with_txn(\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  workspace_id: &uuid::Uuid,\n  member_email: &str,\n  role: AFRole,\n) -> Result<(), AppError> {\n  let role_id: i32 = role.into();\n  sqlx::query!(\n    r#\"\n      INSERT INTO public.af_workspace_member (workspace_id, uid, role_id)\n      SELECT $1, af_user.uid, $3\n      FROM public.af_user\n      WHERE\n        af_user.email = $2\n      ON CONFLICT (workspace_id, uid)\n      DO NOTHING;\n    \"#,\n    workspace_id,\n    member_email,\n    role_id\n  )\n  .execute(txn.deref_mut())\n  .await?;\n\n  Ok(())\n}\n\n#[inline]\npub async fn insert_workspace_invitation(\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  invite_id: &uuid::Uuid,\n  workspace_id: &uuid::Uuid,\n  inviter_uuid: &Uuid,\n  invitee_email: &str,\n  invitee_role: &AFRole,\n) -> Result<(), AppError> {\n  let role_id: i32 = invitee_role.into();\n  sqlx::query!(\n    r#\"\n      INSERT INTO public.af_workspace_invitation (\n          id,\n          workspace_id,\n          inviter,\n          invitee_email,\n          role_id\n      )\n      VALUES (\n        $1,\n        $2,\n        (SELECT uid FROM public.af_user WHERE uuid = $3),\n        $4,\n        $5\n      )\n    \"#,\n    invite_id,\n    workspace_id,\n    inviter_uuid,\n    invitee_email,\n    role_id\n  )\n  .execute(txn.deref_mut())\n  .await?;\n\n  Ok(())\n}\n\npub async fn update_workspace_invitation_set_status_accepted(\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  invitee_uuid: &Uuid,\n  invite_id: &Uuid,\n) -> Result<(), AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n    UPDATE public.af_workspace_invitation\n    SET status = 1\n    WHERE LOWER(invitee_email) = (SELECT LOWER(email) FROM public.af_user WHERE uuid = $1)\n      AND id = $2\n      AND status = 0\n    \"#,\n    invitee_uuid,\n    invite_id,\n  )\n  .execute(txn.deref_mut())\n  .await?;\n  match res.rows_affected() {\n    0 => Err(AppError::RecordNotFound(format!(\n      \"Invitation not found, invitee_uuid: {}, invite_id: {}\",\n      invitee_uuid, invite_id\n    ))),\n    1 => Ok(()),\n    x => Err(\n      anyhow::anyhow!(\n        \"Expected 1 row to be affected, but {} rows were affected\",\n        x\n      )\n      .into(),\n    ),\n  }\n}\n\npub async fn get_invitation_by_id(\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  invite_id: &Uuid,\n) -> Result<AFWorkspaceInvitationMinimal, AppError> {\n  let res = sqlx::query_as!(\n    AFWorkspaceInvitationMinimal,\n    r#\"\n    SELECT\n        workspace_id,\n        inviter AS inviter_uid,\n        (SELECT uid FROM public.af_user WHERE LOWER(email) = LOWER(invitee_email)) AS invitee_uid,\n        status,\n        role_id AS role\n    FROM\n    public.af_workspace_invitation\n    WHERE id = $1\n    \"#,\n    invite_id,\n  )\n  .fetch_one(txn.deref_mut())\n  .await?;\n\n  Ok(res)\n}\n\n#[inline]\npub async fn select_workspace_invitations_for_user(\n  pg_pool: &PgPool,\n  invitee_uuid: &Uuid,\n  status_filter: Option<AFWorkspaceInvitationStatus>,\n) -> Result<Vec<AFWorkspaceInvitation>, AppError> {\n  let res = sqlx::query_as!(\n    AFWorkspaceInvitation,\n    r#\"\n      SELECT\n        i.id AS invite_id,\n        i.workspace_id,\n        w.workspace_name,\n        u_inviter.email AS inviter_email,\n        u_inviter.name AS inviter_name,\n        i.status,\n        i.updated_at,\n        u_inviter.metadata->>'icon_url' AS inviter_icon,\n        w.icon AS workspace_icon,\n        (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n      FROM\n        public.af_workspace_invitation i\n        JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n        JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n        JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n      WHERE\n        LOWER(i.invitee_email) = LOWER(u_invitee.email)\n        AND ($2::SMALLINT IS NULL OR i.status = $2);\n    \"#,\n    invitee_uuid,\n    status_filter.map(|s| s as i16)\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(res)\n}\n\n#[inline]\npub async fn select_workspace_invitation_for_user(\n  pg_pool: &PgPool,\n  invitee_uuid: &Uuid,\n  invite_id: &Uuid,\n) -> Result<AFWorkspaceInvitation, AppError> {\n  let res = sqlx::query_as!(\n    AFWorkspaceInvitation,\n    r#\"\n      SELECT\n        i.id AS invite_id,\n        i.workspace_id,\n        w.workspace_name,\n        u_inviter.email AS inviter_email,\n        u_inviter.name AS inviter_name,\n        i.status,\n        i.updated_at,\n        u_inviter.metadata->>'icon_url' AS inviter_icon,\n        w.icon AS workspace_icon,\n        (SELECT COUNT(*) FROM public.af_workspace_member m WHERE m.workspace_id = i.workspace_id) AS member_count\n      FROM\n        public.af_workspace_invitation i\n        JOIN public.af_workspace w ON i.workspace_id = w.workspace_id\n        JOIN public.af_user u_inviter ON i.inviter = u_inviter.uid\n        JOIN public.af_user u_invitee ON u_invitee.uuid = $1\n      WHERE\n        LOWER(i.invitee_email) = LOWER(u_invitee.email)\n        AND i.id = $2;\n    \"#,\n    invitee_uuid,\n    invite_id,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n  Ok(res)\n}\n\n#[inline]\n#[instrument(level = \"trace\", skip(pool, email, role), err)]\npub async fn upsert_workspace_member(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n  email: &str,\n  role: AFRole,\n) -> Result<(), sqlx::Error> {\n  event!(\n    tracing::Level::TRACE,\n    \"update workspace member: workspace_id:{}, uid {:?}, role:{:?}\",\n    workspace_id,\n    select_uid_from_email(pool, email).await,\n    role\n  );\n\n  let role_id: i32 = role.into();\n  sqlx::query!(\n    r#\"\n        UPDATE af_workspace_member\n        SET\n            role_id = $1\n        WHERE workspace_id = $2 AND uid = (\n            SELECT uid FROM af_user WHERE email = $3\n        )\n        \"#,\n    role_id,\n    workspace_id,\n    email\n  )\n  .execute(pool)\n  .await?;\n\n  Ok(())\n}\n\n#[inline]\npub async fn delete_workspace_members(\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  workspace_id: &Uuid,\n  member_email: &str,\n) -> Result<(), AppError> {\n  let is_owner = sqlx::query_scalar!(\n    r#\"\n  SELECT EXISTS (\n    SELECT 1\n    FROM public.af_workspace\n    WHERE\n        workspace_id = $1\n        AND owner_uid = (\n            SELECT uid FROM public.af_user WHERE email = $2\n        )\n   ) AS \"is_owner\";\n  \"#,\n    workspace_id,\n    member_email\n  )\n  .fetch_one(txn.deref_mut())\n  .await?\n  .unwrap_or(false);\n\n  if is_owner {\n    return Err(AppError::NotEnoughPermissions);\n  }\n\n  sqlx::query!(\n    r#\"\n    DELETE FROM public.af_workspace_member\n    WHERE\n    workspace_id = $1\n    AND uid = (\n        SELECT uid FROM public.af_user WHERE email = $2\n    )\n    -- Ensure the user to be deleted is not the original owner.\n    -- 1. TODO(nathan): User must transfer ownership to another user first.\n    -- 2. User must have at least one workspace\n    AND uid <> (\n        SELECT owner_uid FROM public.af_workspace WHERE workspace_id = $1\n    );\n    \"#,\n    workspace_id,\n    member_email,\n  )\n  .execute(txn.deref_mut())\n  .await?;\n\n  Ok(())\n}\n\npub fn select_workspace_member_perm_stream(\n  pg_pool: &PgPool,\n) -> BoxStream<'_, sqlx::Result<AFWorkspaceMemberPermRow>> {\n  sqlx::query_as!(\n    AFWorkspaceMemberPermRow,\n    \"SELECT uid, role_id as role, workspace_id FROM af_workspace_member\"\n  )\n  .fetch(pg_pool)\n}\n\npub async fn select_email_belongs_to_a_workspace_member(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  email: &str,\n) -> Result<bool, AppError> {\n  let exists = sqlx::query_scalar!(\n    r#\"\n    SELECT EXISTS(\n      SELECT 1\n      FROM public.af_workspace_member\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n      WHERE af_workspace_member.workspace_id = $1\n      AND LOWER(af_user.email) = LOWER($2)\n    ) AS \"exists\";\n    \"#,\n    workspace_id,\n    email\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(exists.unwrap_or(false))\n}\n\npub async fn select_workspace_member_uuid_exclude_guest(\n  pg_pool: &PgPool,\n  workspace_id: &uuid::Uuid,\n) -> Result<Vec<Uuid>, AppError> {\n  let member_uuids = sqlx::query_scalar!(\n    r#\"\n    SELECT\n      af_user.uuid\n    FROM public.af_workspace_member\n        JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n    WHERE af_workspace_member.workspace_id = $1\n    AND role_id != $2\n    \"#,\n    workspace_id,\n    AFRole::Guest as i32,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(member_uuids)\n}\n\n/// returns a list of workspace members, sorted by their creation time.\n#[inline]\npub async fn select_workspace_member_list_exclude_guest(\n  pg_pool: &PgPool,\n  workspace_id: &uuid::Uuid,\n) -> Result<Vec<AFWorkspaceMemberRow>, AppError> {\n  let members = sqlx::query_as!(\n    AFWorkspaceMemberRow,\n    r#\"\n    SELECT\n      af_user.uid,\n      af_user.name,\n      af_user.email,\n      af_user.metadata ->> 'icon_url' AS avatar_url,\n      af_workspace_member.role_id AS role,\n      af_workspace_member.created_at\n    FROM public.af_workspace_member\n        JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n    WHERE af_workspace_member.workspace_id = $1\n    AND role_id != $2\n    ORDER BY af_workspace_member.created_at ASC;\n    \"#,\n    workspace_id,\n    AFRole::Guest as i32,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n  Ok(members)\n}\n\n#[inline]\npub async fn select_workspace_member<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  uid: i64,\n  workspace_id: &Uuid,\n) -> Result<Option<AFWorkspaceMemberRow>, AppError> {\n  let member = sqlx::query_as!(\n    AFWorkspaceMemberRow,\n    r#\"\n    SELECT\n      af_user.uid,\n      af_user.name,\n      af_user.email,\n      af_user.metadata ->> 'icon_url' AS avatar_url,\n      af_workspace_member.role_id AS role,\n      af_workspace_member.created_at\n    FROM public.af_workspace_member\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n    WHERE af_workspace_member.workspace_id = $1\n    AND af_workspace_member.uid = $2\n    \"#,\n    workspace_id,\n    uid,\n  )\n  .fetch_optional(executor)\n  .await?;\n  Ok(member)\n}\n\npub async fn select_workspace_owner<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspaceMemberRow, AppError> {\n  let member = sqlx::query_as!(\n    AFWorkspaceMemberRow,\n    r#\"\n    SELECT\n      af_user.uid,\n      af_user.name,\n      af_user.email,\n      af_user.metadata ->> 'icon_url' AS avatar_url,\n      af_workspace_member.role_id AS role,\n      af_workspace_member.created_at\n    FROM public.af_workspace_member\n    JOIN public.af_workspace USING(workspace_id)\n    JOIN public.af_user ON af_workspace.owner_uid = af_user.uid\n    WHERE af_workspace_member.workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(member)\n}\n\n#[inline]\npub async fn select_workspace_member_by_uuid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  uuid: Uuid,\n  workspace_id: Uuid,\n) -> Result<AFWorkspaceMemberRow, AppError> {\n  let member = sqlx::query_as!(\n    AFWorkspaceMemberRow,\n    r#\"\n    SELECT\n      af_user.uid,\n      af_user.name,\n      af_user.email,\n      af_user.metadata ->> 'icon_url' AS avatar_url,\n      af_workspace_member.role_id AS role,\n      af_workspace_member.created_at\n    FROM public.af_workspace_member\n      JOIN public.af_user ON af_workspace_member.uid = af_user.uid\n    WHERE af_workspace_member.workspace_id = $1\n    AND af_user.uuid = $2\n    \"#,\n    workspace_id,\n    uuid,\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(member)\n}\n\n#[inline]\npub async fn select_user_profile<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<Option<AFUserProfileRow>, AppError> {\n  let user_profile = sqlx::query_as!(\n    AFUserProfileRow,\n    r#\"\n      WITH af_user_row AS (\n        SELECT * FROM af_user WHERE uuid = $1\n      )\n      SELECT\n        af_user_row.uid,\n        af_user_row.uuid,\n        af_user_row.email,\n        af_user_row.password,\n        af_user_row.name,\n        af_user_row.metadata,\n        af_user_row.encryption_sign,\n        af_user_row.deleted_at,\n        af_user_row.updated_at,\n        af_user_row.created_at,\n       (\n         SELECT af_workspace_member.workspace_id\n         FROM af_workspace_member\n         JOIN af_workspace\n           ON af_workspace_member.workspace_id = af_workspace.workspace_id\n         WHERE af_workspace_member.uid = af_user_row.uid\n           AND COALESCE(af_workspace.is_initialized, true) = true\n         ORDER BY af_workspace_member.updated_at DESC\n         LIMIT 1\n       ) AS latest_workspace_id\n      FROM af_user_row\n    \"#,\n    user_uuid\n  )\n  .fetch_optional(executor)\n  .await?;\n  Ok(user_profile)\n}\n\n#[inline]\npub async fn select_workspace<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspaceRow, AppError> {\n  let workspace = sqlx::query_as!(\n    AFWorkspaceRow,\n    r#\"\n      SELECT\n        workspace_id,\n        database_storage_id,\n        owner_uid,\n        owner_profile.name as owner_name,\n        owner_profile.email as owner_email,\n        af_workspace.created_at,\n        workspace_type,\n        af_workspace.deleted_at,\n        workspace_name,\n        icon\n      FROM public.af_workspace\n      JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\n      WHERE af_workspace.workspace_id = $1\n        AND COALESCE(af_workspace.is_initialized, true) = true;\n    \"#,\n    workspace_id\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(workspace)\n}\n\n#[inline]\npub async fn select_workspace_with_count_and_role<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  uid: i64,\n) -> Result<AFWorkspaceRowWithMemberCountAndRole, AppError> {\n  let workspace = sqlx::query_as!(\n    AFWorkspaceRowWithMemberCountAndRole,\n    r#\"\n      WITH workspace_member_count AS (\n        SELECT\n          workspace_id,\n          COUNT(*) AS member_count\n        FROM af_workspace_member\n        WHERE workspace_id = $1 AND role_id != $3\n        GROUP BY workspace_id\n      )\n\n      SELECT\n        af_workspace.workspace_id,\n        database_storage_id,\n        owner_uid,\n        owner_profile.name as owner_name,\n        owner_profile.email as owner_email,\n        af_workspace.created_at,\n        workspace_type,\n        af_workspace.deleted_at,\n        workspace_name,\n        icon,\n        workspace_member_count.member_count AS \"member_count!\",\n        role_id AS \"role!\"\n      FROM public.af_workspace\n      JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\n      JOIN af_workspace_member ON (af_workspace.workspace_id = af_workspace_member.workspace_id\n        AND af_workspace_member.uid = $2)\n      JOIN workspace_member_count ON af_workspace.workspace_id = workspace_member_count.workspace_id\n      WHERE af_workspace.workspace_id = $1\n        AND COALESCE(af_workspace.is_initialized, true) = true;\n    \"#,\n    workspace_id,\n    uid,\n    AFRole::Guest as i32, // Exclude guests from member count\n  )\n  .fetch_one(executor)\n  .await?;\n  Ok(workspace)\n}\n\n#[inline]\npub async fn select_workspace_database_storage_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &str,\n) -> Result<Uuid, AppError> {\n  let workspace_id = Uuid::parse_str(workspace_id)?;\n  let result = sqlx::query!(\n    r#\"\n        SELECT\n            database_storage_id\n        FROM public.af_workspace\n        WHERE workspace_id = $1\n        \"#,\n    workspace_id\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(result.database_storage_id)\n}\n\n#[inline]\npub async fn update_updated_at_of_workspace<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n       UPDATE af_workspace_member\n       SET updated_at = CURRENT_TIMESTAMP\n       WHERE uid = (SELECT uid FROM public.af_user WHERE uuid = $1) AND workspace_id = $2;\n    \"#,\n    user_uuid,\n    workspace_id\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n\n#[inline]\npub async fn update_updated_at_of_workspace_with_uid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  uid: i64,\n  workspace_id: &Uuid,\n  current_timestamp: DateTime<Utc>,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n        UPDATE af_workspace_member\n        SET updated_at = $3\n        WHERE uid = $1\n        AND workspace_id = $2;\n        \"#,\n    uid,\n    workspace_id,\n    current_timestamp\n  )\n  .execute(executor)\n  .await?;\n\n  Ok(())\n}\n\n/// Returns a list of workspaces that the user is part of.\n/// User may be guest.\n#[inline]\npub async fn select_all_user_workspaces<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<Vec<AFWorkspaceRowWithMemberCountAndRole>, AppError> {\n  let workspaces = sqlx::query_as!(\n    AFWorkspaceRowWithMemberCountAndRole,\n    r#\"\n      WITH user_workspace_id AS (\n        SELECT workspace_id\n        FROM af_workspace_member\n        JOIN af_user ON af_workspace_member.uid = af_user.uid\n        WHERE af_user.uuid = $1\n      ),\n      workspace_member_count AS (\n        SELECT\n          workspace_id,\n          COUNT(*) AS member_count\n        FROM af_workspace_member\n        JOIN user_workspace_id USING (workspace_id)\n        WHERE role_id != $2\n        GROUP BY workspace_id\n      )\n\n      SELECT\n        w.workspace_id,\n        w.database_storage_id,\n        w.owner_uid,\n        u.name AS owner_name,\n        u.email AS owner_email,\n        w.created_at,\n        w.workspace_type,\n        w.deleted_at,\n        w.workspace_name,\n        w.icon,\n        wmc.member_count AS \"member_count!\",\n        wm.role_id AS \"role!\"\n      FROM af_workspace w\n      JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n      JOIN public.af_user u ON w.owner_uid = u.uid\n      JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\n      WHERE wm.uid = (\n         SELECT uid FROM public.af_user WHERE uuid = $1\n      )\n      AND COALESCE(w.is_initialized, true) = true;\n    \"#,\n    user_uuid,\n    AFRole::Guest as i32, // Exclude guests from member count\n  )\n  .fetch_all(executor)\n  .await?;\n  Ok(workspaces)\n}\n\n/// Returns a list of workspaces that the user is part of.\n/// User must be at least member.\n#[inline]\npub async fn select_all_user_non_guest_workspaces<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<Vec<AFWorkspaceRowWithMemberCountAndRole>, AppError> {\n  let workspaces = sqlx::query_as!(\n    AFWorkspaceRowWithMemberCountAndRole,\n    r#\"\n      WITH user_workspace_id AS (\n        SELECT workspace_id\n        FROM af_workspace_member\n        JOIN af_user ON af_workspace_member.uid = af_user.uid\n        WHERE af_user.uuid = $1\n      ),\n      workspace_member_count AS (\n        SELECT\n          workspace_id,\n          COUNT(*) AS member_count\n        FROM af_workspace_member\n        JOIN user_workspace_id USING (workspace_id)\n        WHERE role_id != $2\n        GROUP BY workspace_id\n      )\n\n      SELECT\n        w.workspace_id,\n        w.database_storage_id,\n        w.owner_uid,\n        u.name AS owner_name,\n        u.email AS owner_email,\n        w.created_at,\n        w.workspace_type,\n        w.deleted_at,\n        w.workspace_name,\n        w.icon,\n        wmc.member_count AS \"member_count!\",\n        wm.role_id AS \"role!\"\n      FROM af_workspace w\n      JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n      JOIN public.af_user u ON w.owner_uid = u.uid\n      JOIN workspace_member_count wmc ON w.workspace_id = wmc.workspace_id\n      WHERE wm.uid = (\n         SELECT uid FROM public.af_user WHERE uuid = $1\n      )\n      AND wm.role_id != $2\n      AND COALESCE(w.is_initialized, true) = true;\n    \"#,\n    user_uuid,\n    AFRole::Guest as i32,\n  )\n  .fetch_all(executor)\n  .await?;\n  Ok(workspaces)\n}\n\n/// Returns a list of workspace ids that the user is owner of.\n#[inline]\npub async fn select_user_owned_workspaces_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  user_uuid: &Uuid,\n) -> Result<Vec<Uuid>, AppError> {\n  let workspace_ids = sqlx::query_scalar!(\n    r#\"\n      SELECT workspace_id\n      FROM af_workspace\n      WHERE owner_uid = (SELECT uid FROM public.af_user WHERE uuid = $1)\n    \"#,\n    user_uuid\n  )\n  .fetch_all(executor)\n  .await?;\n  Ok(workspace_ids)\n}\n\npub async fn insert_workspace_ids_to_deleted_table<'a, E>(\n  executor: E,\n  workspace_ids: Vec<Uuid>,\n) -> Result<(), AppError>\nwhere\n  E: Executor<'a, Database = Postgres>,\n{\n  if workspace_ids.is_empty() {\n    return Ok(());\n  }\n\n  let query = \"INSERT INTO public.af_workspace_deleted (workspace_id) SELECT unnest($1::uuid[])\";\n  sqlx::query(query)\n    .bind(workspace_ids)\n    .execute(executor)\n    .await?;\n  Ok(())\n}\n\npub async fn update_workspace_status<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  is_initialized: bool,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n    UPDATE public.af_workspace\n    SET is_initialized = $2\n    WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n    is_initialized\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to update workspace status, workspace_id: {}\",\n      workspace_id\n    );\n  }\n  Ok(())\n}\n\npub async fn select_member_count_for_workspaces<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_ids: &[Uuid],\n) -> Result<HashMap<Uuid, i64>, AppError> {\n  let query_res = sqlx::query!(\n    r#\"\n      SELECT workspace_id, COUNT(*) AS member_count\n      FROM af_workspace_member\n      WHERE workspace_id = ANY($1) AND role_id != $2\n      GROUP BY workspace_id\n    \"#,\n    workspace_ids,\n    AFRole::Guest as i32, // Exclude guests from member count\n  )\n  .fetch_all(executor)\n  .await?;\n\n  let mut ret = HashMap::with_capacity(workspace_ids.len());\n  for row in query_res {\n    let count = match row.member_count {\n      Some(c) => c,\n      None => continue,\n    };\n    ret.insert(row.workspace_id, count);\n  }\n\n  Ok(ret)\n}\n\npub async fn select_roles_for_workspaces(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  workspace_ids: &[Uuid],\n) -> Result<HashMap<Uuid, AFRole>, AppError> {\n  let query_res = sqlx::query!(\n    r#\"\n      SELECT workspace_id, role_id\n      FROM af_workspace_member\n      WHERE workspace_id = ANY($1)\n        AND uid = (SELECT uid FROM public.af_user WHERE uuid = $2)\n    \"#,\n    workspace_ids,\n    user_uuid,\n  )\n  .fetch_all(pg_pool)\n  .await?;\n\n  let mut ret = HashMap::with_capacity(workspace_ids.len());\n  for row in query_res {\n    let role = AFRole::from(row.role_id);\n    ret.insert(row.workspace_id, role);\n  }\n\n  Ok(ret)\n}\n\npub async fn select_permission(\n  pool: &PgPool,\n  permission_id: &i64,\n) -> Result<Option<AFPermissionRow>, AppError> {\n  let permission = sqlx::query_as!(\n    AFPermissionRow,\n    r#\"\n      SELECT * FROM public.af_permissions WHERE id = $1\n    \"#,\n    *permission_id as i32\n  )\n  .fetch_optional(pool)\n  .await?;\n  Ok(permission)\n}\n\npub async fn select_permission_from_role_id(\n  pool: &PgPool,\n  role_id: &i64,\n) -> Result<Option<AFPermissionRow>, AppError> {\n  let permission = sqlx::query_as!(\n    AFPermissionRow,\n    r#\"\n    SELECT p.id, p.name, p.access_level, p.description FROM af_permissions p\n    JOIN af_role_permissions rp ON p.id = rp.permission_id\n    WHERE rp.role_id = $1\n    \"#,\n    *role_id as i32\n  )\n  .fetch_optional(pool)\n  .await?;\n  Ok(permission)\n}\n\npub async fn select_workspace_total_collab_bytes(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<i64, AppError> {\n  let sum = sqlx::query_scalar!(\n    r#\"\n    SELECT SUM(len) FROM af_collab WHERE workspace_id = $1\n    \"#,\n    workspace_id\n  )\n  .fetch_one(pool)\n  .await?;\n\n  match sum {\n    Some(s) => Ok(s),\n    None => Err(AppError::RecordNotFound(format!(\n      \"Failed to get total collab bytes for workspace_id: {}\",\n      workspace_id\n    ))),\n  }\n}\n\n#[inline]\npub async fn select_workspace_name_from_workspace_id(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Option<String>, AppError> {\n  let workspace_name = sqlx::query_scalar!(\n    r#\"\n      SELECT workspace_name\n      FROM public.af_workspace\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id\n  )\n  .fetch_one(pool)\n  .await?;\n  Ok(workspace_name)\n}\n\n#[inline]\npub async fn select_workspace_member_count_from_workspace_id(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Option<i64>, AppError> {\n  let workspace_count = sqlx::query_scalar!(\n    r#\"\n      SELECT COUNT(*)\n      FROM public.af_workspace_member\n      WHERE workspace_id = $1\n      AND role_id != $2\n    \"#,\n    workspace_id,\n    AFRole::Guest as i32, // Exclude guests from member count\n  )\n  .fetch_one(pool)\n  .await?;\n  Ok(workspace_count)\n}\n\n#[inline]\npub async fn select_workspace_pending_invitations(\n  pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<HashMap<String, Uuid>, AppError> {\n  let res = sqlx::query!(\n    r#\"\n      SELECT id, invitee_email\n      FROM public.af_workspace_invitation\n      WHERE workspace_id = $1\n      AND status = 0\n    \"#,\n    workspace_id\n  )\n  .fetch_all(pool)\n  .await?;\n\n  let inv_id_by_email = res\n    .into_iter()\n    .map(|row| (row.invitee_email, row.id))\n    .collect::<HashMap<String, Uuid>>();\n\n  Ok(inv_id_by_email)\n}\n\n#[inline]\npub async fn is_workspace_exist<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<bool, AppError> {\n  let exists = sqlx::query_scalar!(\n    r#\"\n    SELECT EXISTS(\n      SELECT 1\n      FROM af_workspace\n      WHERE workspace_id = $1\n    ) AS user_exists;\n  \"#,\n    workspace_id\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(exists.unwrap_or(false))\n}\n\npub async fn select_workspace_settings<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Option<AFWorkspaceSettings>, AppError> {\n  let json = sqlx::query_scalar!(\n    r#\"SElECT settings FROM af_workspace WHERE workspace_id = $1\"#,\n    workspace_id\n  )\n  .fetch_one(executor)\n  .await?;\n\n  match json {\n    None => Ok(None),\n    Some(value) => {\n      let settings: AFWorkspaceSettings = serde_json::from_value(value)?;\n      Ok(Some(settings))\n    },\n  }\n}\npub async fn upsert_workspace_settings(\n  tx: &mut Transaction<'_, Postgres>,\n  workspace_id: &Uuid,\n  settings: &AFWorkspaceSettings,\n) -> Result<(), AppError> {\n  let json = serde_json::to_value(settings)?;\n  sqlx::query!(\n    r#\"\n      UPDATE af_workspace\n      SET settings = $1\n      WHERE workspace_id = $2\n    \"#,\n    json,\n    workspace_id\n  )\n  .execute(tx.deref_mut())\n  .await?;\n\n  if settings.disable_search_indexing {\n    sqlx::query!(\n      r#\"\n        DELETE FROM af_collab_embeddings e\n        USING af_collab c\n        WHERE e.oid = c.oid\n          AND c.workspace_id = $1\n      \"#,\n      workspace_id\n    )\n    .execute(tx.deref_mut())\n    .await?;\n  }\n\n  Ok(())\n}\n\npub async fn select_owner_of_published_collab<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: &Uuid,\n) -> Result<Uuid, AppError> {\n  let res = sqlx::query!(\n    r#\"\n      SELECT af.uuid\n      FROM af_published_collab apc\n      JOIN af_user af ON af.uid = apc.published_by\n      WHERE view_id = $1\n    \"#,\n    view_id,\n  )\n  .fetch_one(executor)\n  .await?;\n\n  Ok(res.uuid)\n}\n\npub async fn select_comments_for_published_view_ordered_by_recency<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  view_id: &Uuid,\n  user_uuid: &Option<Uuid>,\n  page_owner_uuid: &Uuid,\n) -> Result<Vec<GlobalComment>, AppError> {\n  let user_uuid = user_uuid.unwrap_or(Uuid::nil());\n  let is_page_owner = user_uuid == *page_owner_uuid;\n  let comment_rows = sqlx::query_as!(\n    AFGlobalCommentRow,\n    r#\"\n      SELECT\n        avc.comment_id,\n        avc.created_at,\n        avc.updated_at AS last_updated_at,\n        avc.content,\n        avc.reply_comment_id,\n        avc.is_deleted,\n        (au.uuid, au.name, au.email, au.metadata ->> 'icon_url') AS \"user: AFWebUserWithEmailColumn\",\n        (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\n      FROM af_published_view_comment avc\n      LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\n      WHERE view_id = $1\n      ORDER BY avc.created_at DESC\n    \"#,\n    view_id,\n    is_page_owner,\n    user_uuid,\n  )\n  .fetch_all(executor)\n  .await?;\n  let comments = comment_rows.into_iter().map(|row| row.into()).collect();\n  Ok(comments)\n}\n\npub async fn insert_comment_to_published_view<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  view_id: &Uuid,\n  user_uuid: &Uuid,\n  content: &str,\n  reply_comment_id: &Option<Uuid>,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      INSERT INTO af_published_view_comment (view_id, created_by, content, reply_comment_id)\n      VALUES ($1, (SELECT uid FROM af_user WHERE uuid = $2), $3, $4)\n    \"#,\n    view_id,\n    user_uuid,\n    content,\n    reply_comment_id.clone(),\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to insert comment to published view, view_id: {}, user_id: {}, content: {}, rows_affected: {}\",\n      view_id, user_uuid, content, res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\npub async fn update_comment_deletion_status<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  comment_id: &Uuid,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      UPDATE af_published_view_comment\n      SET is_deleted = true\n      WHERE comment_id = $1\n    \"#,\n    comment_id,\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to update deletion status for comment, comment_id: {}, rows_affected: {}\",\n      comment_id,\n      res.rows_affected()\n    );\n  }\n\n  Ok(())\n}\n\npub async fn select_reactions_for_published_view_ordered_by_reaction_type_creation_time<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  view_id: &Uuid,\n) -> Result<Vec<Reaction>, AppError> {\n  let reaction_rows = sqlx::query_as!(\n    AFReactionRow,\n    r#\"\n      SELECT\n        avr.comment_id,\n        avr.reaction_type,\n        ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserWithEmailColumn>\"\n      FROM af_published_view_reaction avr\n      INNER JOIN af_user au ON avr.created_by = au.uid\n      WHERE view_id = $1\n      GROUP BY comment_id, reaction_type\n      ORDER BY MIN(avr.created_at)\n    \"#,\n    view_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  let reactions = reaction_rows.into_iter().map(|x| x.into()).collect();\n  Ok(reactions)\n}\n\npub async fn select_reactions_for_comment_ordered_by_reaction_type_creation_time<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  comment_id: &Uuid,\n) -> Result<Vec<Reaction>, AppError> {\n  let reaction_rows = sqlx::query_as!(\n    AFReactionRow,\n    r#\"\n      SELECT\n        avr.reaction_type,\n        ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserWithEmailColumn>\",\n        avr.comment_id\n      FROM af_published_view_reaction avr\n      INNER JOIN af_user au ON avr.created_by = au.uid\n      WHERE comment_id = $1\n      GROUP BY comment_id, reaction_type\n      ORDER BY MIN(avr.created_at)\n    \"#,\n    comment_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  let reactions = reaction_rows.into_iter().map(|x| x.into()).collect();\n  Ok(reactions)\n}\n\npub async fn insert_reaction_on_comment<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  comment_id: &Uuid,\n  view_id: &Uuid,\n  user_uuid: &Uuid,\n  reaction_type: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      INSERT INTO af_published_view_reaction (comment_id, view_id, created_by, reaction_type)\n      VALUES ($1, $2, (SELECT uid FROM af_user WHERE uuid = $3), $4)\n    \"#,\n    comment_id,\n    view_id,\n    user_uuid,\n    reaction_type,\n  )\n  .execute(executor)\n  .await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to insert reaction to comment, comment_id: {}, user_id: {}, reaction_type: {}, rows_affected: {}\",\n      comment_id, user_uuid, reaction_type, res.rows_affected()\n    );\n  };\n\n  Ok(())\n}\n\npub async fn delete_reaction_from_comment<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  comment_id: &Uuid,\n  user_uuid: &Uuid,\n  reaction_type: &str,\n) -> Result<(), AppError> {\n  let res = sqlx::query!(\n    r#\"\n      DELETE FROM af_published_view_reaction\n      WHERE comment_id = $1 AND created_by = (SELECT uid FROM af_user WHERE uuid = $2) AND reaction_type = $3\n    \"#,\n    comment_id,\n    user_uuid,\n    reaction_type,\n  ).execute(executor).await?;\n\n  if res.rows_affected() != 1 {\n    tracing::error!(\n      \"Failed to delete reaction from published comment, comment_id: {}, user_id: {}, reaction_type: {}, rows_affected: {}\",\n      comment_id, user_uuid, reaction_type, res.rows_affected()\n    );\n  };\n\n  Ok(())\n}\n\npub async fn select_user_is_invitee_for_workspace_invitation(\n  pg_pool: &PgPool,\n  invitee_uuid: &Uuid,\n  invite_id: &Uuid,\n) -> Result<bool, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT EXISTS(\n        SELECT 1\n        FROM af_workspace_invitation\n        WHERE id = $1 AND LOWER(invitee_email) = (SELECT LOWER(email) FROM af_user WHERE uuid = $2)\n      )\n    \"#,\n    invite_id,\n    invitee_uuid,\n  )\n  .fetch_one(pg_pool)\n  .await?;\n  res.map_or(Ok(false), Ok)\n}\n\npub async fn select_import_task(\n  pg_pool: &PgPool,\n  task_id: &Uuid,\n) -> Result<AFImportTask, AppError> {\n  let query = String::from(\"SELECT * FROM af_import_task WHERE task_id = $1\");\n  let import_task = sqlx::query_as::<_, AFImportTask>(&query)\n    .bind(task_id)\n    .fetch_one(pg_pool)\n    .await?;\n  Ok(import_task)\n}\n\n/// Get the import task for the user\n/// Status of the file import (e.g., 0 for pending, 1 for completed, 2 for failed)\npub async fn select_import_task_by_state(\n  user_id: i64,\n  pg_pool: &PgPool,\n  filter_by_status: Option<ImportTaskState>,\n) -> Result<Vec<AFImportTask>, AppError> {\n  let mut query = String::from(\"SELECT * FROM af_import_task WHERE created_by = $1\");\n  if filter_by_status.is_some() {\n    query.push_str(\" AND status = $2\");\n  }\n  query.push_str(\" ORDER BY created_at DESC\");\n\n  let import_tasks = if let Some(status) = filter_by_status {\n    sqlx::query_as::<_, AFImportTask>(&query)\n      .bind(user_id)\n      .bind(status as i32)\n      .fetch_all(pg_pool)\n      .await?\n  } else {\n    sqlx::query_as::<_, AFImportTask>(&query)\n      .bind(user_id)\n      .fetch_all(pg_pool)\n      .await?\n  };\n\n  Ok(import_tasks)\n}\n\n#[derive(Clone, Debug)]\npub enum ImportTaskState {\n  Pending = 0,\n  Completed = 1,\n  Failed = 2,\n  Expire = 3,\n  Cancel = 4,\n}\n\nimpl From<i16> for ImportTaskState {\n  fn from(val: i16) -> Self {\n    match val {\n      0 => ImportTaskState::Pending,\n      1 => ImportTaskState::Completed,\n      2 => ImportTaskState::Failed,\n      4 => ImportTaskState::Cancel,\n      _ => ImportTaskState::Pending,\n    }\n  }\n}\n\n/// Update import task status\n///  0 => Pending,\n///   1 => Completed,\n///   2 => Failed,\n///   3 => Expire,\npub async fn update_import_task_status<'a, E: Executor<'a, Database = Postgres>>(\n  task_id: &Uuid,\n  new_status: ImportTaskState,\n  executor: E,\n) -> Result<(), AppError> {\n  let query = \"UPDATE af_import_task SET status = $1 WHERE task_id = $2\";\n  sqlx::query(query)\n    .bind(new_status as i16)\n    .bind(task_id)\n    .execute(executor)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to update status for task_id {}: {:?}\",\n        task_id,\n        err\n      ))\n    })?;\n\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn insert_import_task(\n  uid: i64,\n  task_id: Uuid,\n  file_size: i64,\n  workspace_id: String,\n  created_by: i64,\n  metadata: Option<serde_json::Value>,\n  presigned_url: Option<String>,\n  pg_pool: &PgPool,\n) -> Result<(), AppError> {\n  let query = r#\"\n        INSERT INTO af_import_task (task_id, file_size, workspace_id, created_by, status, metadata, uid, file_url)\n        VALUES ($1, $2, $3, $4, $5, COALESCE($6, '{}'), $7, $8)\n    \"#;\n\n  sqlx::query(query)\n    .bind(task_id)\n    .bind(file_size)\n    .bind(workspace_id)\n    .bind(created_by)\n    .bind(ImportTaskState::Pending as i32)\n    .bind(metadata)\n    .bind(uid)\n    .bind(presigned_url)\n    .execute(pg_pool)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to create a new import task: {:?}\",\n        err\n      ))\n    })?;\n\n  Ok(())\n}\n\npub async fn update_import_task_metadata(\n  task_id: Uuid,\n  new_metadata: serde_json::Value,\n  pg_pool: &PgPool,\n) -> Result<(), AppError> {\n  let query = r#\"\n        UPDATE af_import_task\n        SET metadata = metadata || $1\n        WHERE task_id = $2\n    \"#;\n\n  sqlx::query(query)\n    .bind(new_metadata)\n    .bind(task_id)\n    .execute(pg_pool)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to update metadata for task_id {}: {:?}\",\n        task_id,\n        err\n      ))\n    })?;\n\n  Ok(())\n}\n\n#[inline]\npub async fn select_publish_name_exists(\n  pg_pool: &PgPool,\n  workspace_uuid: &Uuid,\n  publish_name: &str,\n) -> Result<bool, AppError> {\n  let exists = sqlx::query_scalar!(\n    r#\"\n      SELECT EXISTS(\n        SELECT 1\n        FROM af_published_collab\n        WHERE workspace_id = $1\n          AND publish_name = $2\n          AND unpublished_at IS NULL\n      )\n    \"#,\n    workspace_uuid,\n    publish_name\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(exists.unwrap_or(false))\n}\n\n#[inline]\npub async fn select_view_id_from_publish_name(\n  pg_pool: &PgPool,\n  workspace_uuid: &Uuid,\n  publish_name: &str,\n) -> Result<Option<Uuid>, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT view_id\n      FROM af_published_collab\n      WHERE workspace_id = $1\n        AND unpublished_at IS NULL\n        AND publish_name = $2\n    \"#,\n    workspace_uuid,\n    publish_name\n  )\n  .fetch_optional(pg_pool)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_invited_workspace_id(\n  pg_pool: &PgPool,\n  invitation_code: &str,\n) -> Result<Uuid, AppError> {\n  let res = sqlx::query_scalar!(\n    r#\"\n      SELECT workspace_id\n      FROM af_workspace_invite_code\n      WHERE invite_code = $1\n        AND (expires_at IS NULL OR expires_at > NOW())\n    \"#,\n    invitation_code\n  )\n  .fetch_one(pg_pool)\n  .await?;\n\n  Ok(res)\n}\n\npub async fn select_invitation_code_info<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  invite_code: &str,\n  uid: i64,\n) -> Result<Vec<InvitationCodeInfo>, AppError> {\n  let info_list = sqlx::query_as!(\n    InvitationCodeInfo,\n    r#\"\n      WITH invited_workspace_member AS (\n        SELECT\n          invite_code,\n          COUNT(*) AS member_count,\n          COUNT(CASE WHEN uid = $2 THEN uid END) > 0 AS is_member\n        FROM af_workspace_invite_code\n        JOIN af_workspace_member USING (workspace_id)\n        WHERE invite_code = $1\n        AND (expires_at IS NULL OR expires_at > NOW())\n        GROUP BY invite_code\n      )\n      SELECT\n      workspace_id,\n      owner_profile.name AS \"owner_name!\",\n      owner_profile.metadata ->> 'icon_url' AS owner_avatar,\n      af_workspace.workspace_name AS \"workspace_name!\",\n      af_workspace.icon AS workspace_icon_url,\n      invited_workspace_member.member_count AS \"member_count!\",\n      invited_workspace_member.is_member AS \"is_member!\"\n      FROM af_workspace_invite_code\n      JOIN af_workspace USING (workspace_id)\n      JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n      JOIN invited_workspace_member USING (invite_code)\n      WHERE invite_code = $1\n    \"#,\n    invite_code,\n    uid\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(info_list)\n}\n\npub async fn upsert_workspace_member_uid<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  uid: i64,\n  role: AFRole,\n) -> Result<(), AppError> {\n  let role_id = role as i32;\n  sqlx::query!(\n    r#\"\n      INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n      VALUES ($1, $2, $3)\n      ON CONFLICT (workspace_id, uid) DO NOTHING\n    \"#,\n    workspace_id,\n    uid,\n    role_id,\n  )\n  .execute(executor)\n  .await?;\n\n  Ok(())\n}\n\npub async fn select_invite_code_for_workspace_id<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Option<String>, AppError> {\n  let code = sqlx::query_scalar!(\n    r#\"\n      SELECT invite_code\n      FROM af_workspace_invite_code\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .fetch_optional(executor)\n  .await?;\n\n  Ok(code)\n}\n\npub async fn delete_all_invite_code_for_workspace<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      DELETE FROM af_workspace_invite_code\n      WHERE workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .execute(executor)\n  .await?;\n\n  Ok(())\n}\n\npub async fn insert_workspace_invite_code<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  code: &str,\n  expires_at: Option<&chrono::DateTime<Utc>>,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      INSERT INTO af_workspace_invite_code (workspace_id, invite_code, expires_at)\n      VALUES ($1, $2, $3)\n    \"#,\n    workspace_id,\n    code,\n    expires_at.map(|dt| dt.naive_utc()),\n  )\n  .execute(executor)\n  .await?;\n\n  Ok(())\n}\n\n#[inline]\npub async fn select_workspace_member_uids<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Vec<i64>, AppError> {\n  let member_uids = sqlx::query_scalar!(\n    r#\"\n      SELECT uid\n      FROM af_workspace_member\n      WHERE workspace_id = $1\n      ORDER BY created_at ASC\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(member_uids)\n}\n\npub async fn select_workspace_mentionable_members_or_guests<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Vec<MentionableWorkspaceMemberOrGuest>, AppError> {\n  let members = sqlx::query_as!(\n    MentionableWorkspaceMemberOrGuest,\n    r#\"\n      SELECT\n        au.uuid,\n        COALESCE(awmp.name, au.name) AS \"name!\",\n        au.email,\n        awm.role_id AS \"role!\",\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n        awmp.cover_image_url,\n        awmp.custom_image_url,\n        awmp.description\n      FROM af_workspace_member awm\n      JOIN af_user au ON awm.uid = au.uid\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n      WHERE awm.workspace_id = $1\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(members)\n}\n\npub async fn select_workspace_mentionable_members_or_guests_with_last_mentioned_time<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: &Uuid,\n) -> Result<Vec<MentionableWorkspaceMemberOrGuestWithLastMentionedTime>, AppError> {\n  let members = sqlx::query_as!(\n    MentionableWorkspaceMemberOrGuestWithLastMentionedTime,\n    r#\"\n      WITH last_mentioned AS (\n        SELECT\n          person_id,\n          MAX(mentioned_at) AS last_mentioned_at\n        FROM af_page_mention\n        WHERE workspace_id = $1\n        GROUP BY person_id\n      )\n\n      SELECT\n        au.uuid,\n        COALESCE(awmp.name, au.name) AS \"name!\",\n        au.email,\n        awm.role_id AS \"role!\",\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n        awmp.cover_image_url,\n        awmp.custom_image_url,\n        awmp.description,\n        lm.last_mentioned_at\n      FROM af_workspace_member awm\n      JOIN af_user au ON awm.uid = au.uid\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n      LEFT JOIN last_mentioned lm ON au.uuid = lm.person_id\n      WHERE awm.workspace_id = $1\n      ORDER BY lm.last_mentioned_at DESC NULLS LAST\n    \"#,\n    workspace_id,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(members)\n}\n\npub async fn select_workspace_mentionable_member_or_guest_by_uuid<\n  'a,\n  E: Executor<'a, Database = Postgres>,\n>(\n  executor: E,\n  workspace_id: &Uuid,\n  user_id: &Uuid,\n) -> Result<Option<MentionableWorkspaceMemberOrGuest>, AppError> {\n  let member = sqlx::query_as!(\n    MentionableWorkspaceMemberOrGuest,\n    r#\"\n      SELECT\n        au.uuid,\n        COALESCE(awmp.name, au.name) AS \"name!\",\n        au.email,\n        awm.role_id AS \"role!\",\n        COALESCE(awmp.avatar_url, au.metadata ->> 'icon_url') AS \"avatar_url\",\n        awmp.cover_image_url,\n        awmp.custom_image_url,\n        awmp.description\n      FROM af_workspace_member awm\n      JOIN af_user au ON awm.uid = au.uid\n      LEFT JOIN af_workspace_member_profile awmp ON (awm.uid = awmp.uid AND awm.workspace_id = awmp.workspace_id)\n      WHERE awm.workspace_id = $1\n      AND au.uuid = $2\n    \"#,\n    workspace_id,\n    user_id,\n  )\n  .fetch_optional(executor)\n  .await?;\n\n  Ok(member)\n}\n\npub async fn upsert_workspace_member_profile<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  uid: i64,\n  updated_profile: &WorkspaceMemberProfile,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      INSERT INTO af_workspace_member_profile (workspace_id, uid, name, avatar_url, cover_image_url, custom_image_url, description)\n      VALUES ($1, $2, $3, $4, $5, $6, $7)\n      ON CONFLICT (workspace_id, uid) DO UPDATE\n      SET name = EXCLUDED.name,\n          avatar_url = EXCLUDED.avatar_url,\n          cover_image_url = EXCLUDED.cover_image_url,\n          custom_image_url = EXCLUDED.custom_image_url,\n          description = EXCLUDED.description\n    \"#,\n    workspace_id,\n    uid,\n    updated_profile.name,\n    updated_profile.avatar_url,\n    updated_profile.cover_image_url,\n    updated_profile.custom_image_url,\n    updated_profile.description\n  )\n  .execute(executor)\n  .await?;\n\n  Ok(())\n}\n\npub async fn select_page_mentions_by_user<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  uid: i64,\n) -> Result<Vec<Uuid>, AppError> {\n  let mentions = sqlx::query_scalar!(\n    r#\"\n      SELECT\n        person_id\n      FROM af_page_mention\n      WHERE workspace_id = $1\n        AND mentioned_by = $2\n    \"#,\n    workspace_id,\n    uid,\n  )\n  .fetch_all(executor)\n  .await?;\n\n  Ok(mentions)\n}\n\npub async fn upsert_page_mention<'a, E: Executor<'a, Database = Postgres>>(\n  executor: E,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n  uid: i64,\n  update: &PageMentionUpdate,\n) -> Result<(), AppError> {\n  sqlx::query!(\n    r#\"\n      INSERT INTO af_page_mention (workspace_id, view_id, view_name, person_id, block_id, mentioned_by, mentioned_at, require_notification)\n      VALUES ($1, $2, $3, $4, $5, $6, current_timestamp, $7)\n      ON CONFLICT (workspace_id, view_id, person_id) DO UPDATE\n      SET mentioned_by = EXCLUDED.mentioned_by,\n          mentioned_at = EXCLUDED.mentioned_at,\n          block_id = EXCLUDED.block_id,\n          require_notification = EXCLUDED.require_notification,\n          notified = false\n    \"#,\n    workspace_id,\n    view_id,\n    update.view_name,\n    update.person_id,\n    update.block_id,\n    uid,\n    update.require_notification,\n  )\n  .execute(executor)\n  .await?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/database-entity/Cargo.toml",
    "content": "[package]\nname = \"database-entity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nserde.workspace = true\nserde_json.workspace = true\ncollab-entity = { workspace = true }\nvalidator = { workspace = true, features = [\"validator_derive\", \"derive\"] }\nchrono = { version = \"0.4\", features = [\"serde\"] }\nuuid = { workspace = true, features = [\"serde\", \"v4\"] }\nthiserror = \"1.0.56\"\ntracing = \"0.1\"\nserde_repr = \"0.1.18\"\nbincode = \"1.3.3\"\nbytes.workspace = true\nprost.workspace = true\ninfra.workspace = true\n"
  },
  {
    "path": "libs/database-entity/src/dto.rs",
    "content": "use crate::error::EntityError;\nuse crate::error::EntityError::{DeserializationError, InvalidData};\n\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse collab_entity::CollabType;\nuse collab_entity::{proto, EncodedCollab};\nuse infra::validate::{validate_not_empty_payload, validate_not_empty_str};\nuse prost::Message;\nuse serde::{Deserialize, Serialize};\n\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse std::cmp::Ordering;\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse std::ops::{Deref, DerefMut};\nuse std::str::FromStr;\nuse tracing::error;\nuse uuid::Uuid;\nuse validator::Validate;\n\nmod uuid_str {\n  use serde::Deserialize;\n  use uuid::Uuid;\n\n  pub fn serialize<S>(uuid: &Uuid, serializer: S) -> Result<S::Ok, S::Error>\n  where\n    S: serde::Serializer,\n  {\n    serializer.serialize_str(&uuid.to_string())\n  }\n\n  pub fn deserialize<'de, D>(deserializer: D) -> Result<Uuid, D::Error>\n  where\n    D: serde::Deserializer<'de>,\n  {\n    let s = String::deserialize(deserializer)?;\n    Uuid::parse_str(&s).map_err(serde::de::Error::custom)\n  }\n}\n\n/// The default compression level of ZSTD-compressed collabs.\npub const ZSTD_COMPRESSION_LEVEL: i32 = 3;\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct CreateCollabParams {\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n\n  #[validate(custom(function = \"validate_not_empty_payload\"))]\n  pub encoded_collab_v1: Vec<u8>,\n\n  pub collab_type: CollabType,\n}\n\nimpl From<(Uuid, CollabParams)> for CreateCollabParams {\n  fn from((workspace_id, collab_params): (Uuid, CollabParams)) -> Self {\n    Self {\n      workspace_id,\n      object_id: collab_params.object_id,\n      encoded_collab_v1: collab_params.encoded_collab_v1.to_vec(),\n      collab_type: collab_params.collab_type,\n    }\n  }\n}\n\nimpl CreateCollabParams {\n  pub fn split(self) -> (CollabParams, Uuid) {\n    (\n      CollabParams {\n        object_id: self.object_id,\n        encoded_collab_v1: Bytes::from(self.encoded_collab_v1),\n        collab_type: self.collab_type,\n        updated_at: None,\n      },\n      self.workspace_id,\n    )\n  }\n\n  pub fn to_bytes(&self) -> Result<Vec<u8>, bincode::Error> {\n    bincode::serialize(self)\n  }\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::Error> {\n    bincode::deserialize(bytes)\n  }\n}\n\npub struct CollabIndexParams {}\n\npub struct PendingCollabWrite {\n  pub workspace_id: Uuid,\n  pub uid: i64,\n  pub params: CollabParams,\n}\n\nimpl PendingCollabWrite {\n  pub fn new(workspace_id: Uuid, uid: i64, params: CollabParams) -> Self {\n    PendingCollabWrite {\n      workspace_id,\n      uid,\n      params,\n    }\n  }\n}\n\n#[derive(Debug)]\npub struct CollabUpdateData {\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n  pub encoded_collab: EncodedCollab,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, Clone, PartialEq, Validate)]\npub struct CollabParams {\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n\n  /// Minimal size of Yrs update is 2 bytes (`[0,0]`) for\n  /// an empty update in lib0 v1 encoding.\n  /// (see: https://github.com/y-crdt/y-crdt/blob/c695dbc8f4c7a80fa03eb656f7ad025b6a2e908b/yrs/src/update.rs#L99).\n  #[validate(length(min = 2))]\n  pub encoded_collab_v1: Bytes,\n  pub updated_at: Option<DateTime<Utc>>,\n}\n\nimpl Display for CollabParams {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"object_id: {}, collab_type: {:?}, size:{}\",\n      self.object_id,\n      self.collab_type,\n      self.encoded_collab_v1.len()\n    )\n  }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct CreateCollabData {\n  pub object_id: Uuid,\n  pub encoded_collab_v1: Bytes,\n  pub collab_type: CollabType,\n}\n\nimpl CreateCollabData {\n  pub fn to_bytes(&self) -> Result<Vec<u8>, bincode::Error> {\n    bincode::serialize(self)\n  }\n\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::Error> {\n    match bincode::deserialize(bytes) {\n      Ok(value) => Ok(value),\n      Err(_) => {\n        // fallback to deserialize into older version\n        let old: CreateCollabDataV0 = bincode::deserialize(bytes)?;\n        Ok(Self {\n          object_id: old.object_id,\n          encoded_collab_v1: old.encoded_collab_v1.into(),\n          collab_type: old.collab_type,\n        })\n      },\n    }\n  }\n\n  pub fn to_proto(&self) -> proto::CollabParams {\n    proto::CollabParams {\n      object_id: self.object_id.to_string(),\n      encoded_collab: self.encoded_collab_v1.to_vec(),\n      collab_type: self.collab_type.to_proto() as i32,\n      embeddings: None,\n    }\n  }\n\n  pub fn to_protobuf_bytes(&self) -> Vec<u8> {\n    self.to_proto().encode_to_vec()\n  }\n\n  pub fn from_protobuf_bytes(bytes: &[u8]) -> Result<Self, EntityError> {\n    match proto::CollabParams::decode(bytes) {\n      Ok(proto) => Self::try_from(proto),\n      Err(err) => Err(DeserializationError(err.to_string())),\n    }\n  }\n}\n\nimpl From<CollabParams> for CreateCollabData {\n  fn from(value: CollabParams) -> Self {\n    Self {\n      object_id: value.object_id,\n      encoded_collab_v1: value.encoded_collab_v1,\n      collab_type: value.collab_type,\n    }\n  }\n}\n\nimpl From<CreateCollabData> for CollabParams {\n  fn from(value: CreateCollabData) -> Self {\n    Self {\n      object_id: value.object_id,\n      encoded_collab_v1: value.encoded_collab_v1,\n      collab_type: value.collab_type,\n      updated_at: None,\n    }\n  }\n}\n\nimpl TryFrom<proto::CollabParams> for CreateCollabData {\n  type Error = EntityError;\n\n  fn try_from(proto: proto::CollabParams) -> Result<Self, Self::Error> {\n    let collab_type_proto = proto::CollabType::try_from(proto.collab_type).unwrap();\n    let collab_type = CollabType::from_proto(&collab_type_proto);\n    Ok(Self {\n      object_id: Uuid::from_str(&proto.object_id)\n        .map_err(|e| EntityError::DeserializationError(e.to_string()))?,\n      encoded_collab_v1: Bytes::from(proto.encoded_collab),\n      collab_type,\n    })\n  }\n}\n\n#[derive(Serialize, Deserialize)]\nstruct CreateCollabDataV0 {\n  #[serde(with = \"uuid_str\")]\n  object_id: Uuid,\n  encoded_collab_v1: Vec<u8>,\n  collab_type: CollabType,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct BatchCreateCollabParams {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub workspace_id: String,\n  pub params_list: Vec<CreateCollabData>,\n}\n\nimpl BatchCreateCollabParams {\n  pub fn to_bytes(&self) -> Result<Vec<u8>, bincode::Error> {\n    bincode::serialize(self)\n  }\n\n  pub fn from_bytes(bytes: &[u8]) -> Result<Self, bincode::Error> {\n    bincode::deserialize(bytes)\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateCollabWebParams {\n  pub doc_state: Vec<u8>,\n  pub collab_type: CollabType,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct DeleteCollabParams {\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct InsertSnapshotParams {\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  #[validate(custom(function = \"validate_not_empty_payload\"))]\n  pub doc_state: Bytes,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n  pub collab_type: CollabType,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SnapshotData {\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  pub encoded_collab_v1: Vec<u8>,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct QuerySnapshotParams {\n  pub snapshot_id: i64,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct QueryCollabParams {\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n  #[validate(nested)]\n  pub inner: QueryCollab,\n}\n\nimpl Display for QueryCollabParams {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"workspace_id: {}, object_id: {}, collab_type: {:?}\",\n      self.workspace_id, self.object_id, self.collab_type\n    )\n  }\n}\n\nimpl QueryCollabParams {\n  pub fn new(object_id: Uuid, collab_type: CollabType, workspace_id: Uuid) -> Self {\n    let inner = QueryCollab {\n      object_id,\n      collab_type,\n    };\n    Self {\n      workspace_id,\n      inner,\n    }\n  }\n}\n\nimpl Deref for QueryCollabParams {\n  type Target = QueryCollab;\n\n  fn deref(&self) -> &Self::Target {\n    &self.inner\n  }\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct QueryCollab {\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n}\nimpl QueryCollab {\n  pub fn new(object_id: Uuid, collab_type: CollabType) -> Self {\n    Self {\n      object_id,\n      collab_type,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct BatchQueryCollabParams(pub Vec<QueryCollab>);\n\nimpl Deref for BatchQueryCollabParams {\n  type Target = Vec<QueryCollab>;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl DerefMut for BatchQueryCollabParams {\n  fn deref_mut(&mut self) -> &mut Self::Target {\n    &mut self.0\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AFSnapshotMeta {\n  pub snapshot_id: i64,\n  pub object_id: String,\n  pub created_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AFSnapshotMetas(pub Vec<AFSnapshotMeta>);\n\n#[derive(Debug, Clone, Deserialize)]\npub struct QueryObjectSnapshotParams {\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AFBlobRecord {\n  pub file_id: String,\n}\n\nimpl AFBlobRecord {\n  pub fn new(file_id: String) -> Self {\n    Self { file_id }\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq)]\npub enum QueryCollabResult {\n  Success { encode_collab_v1: Vec<u8> },\n  Failed { error: String },\n}\n\n#[derive(Serialize, Deserialize)]\npub struct BatchQueryCollabResult(pub HashMap<Uuid, QueryCollabResult>);\n\n#[derive(Serialize, Deserialize)]\npub struct WorkspaceUsage {\n  pub total_document_size: i64,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct InsertCollabMemberParams {\n  pub uid: i64,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  pub access_level: AFAccessLevel,\n}\n\npub type UpdateCollabMemberParams = InsertCollabMemberParams;\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct WorkspaceCollabIdentify {\n  pub uid: i64,\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct UpdatePublishNamespace {\n  pub old_namespace: String,\n  pub new_namespace: String,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct UpdateDefaultPublishView {\n  pub view_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct DefaultPublishViewInfoMeta {\n  pub info: PublishInfo,\n  pub meta: serde_json::Value,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct QueryCollabMembers {\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct QueryWorkspaceMember {\n  #[serde(with = \"uuid_str\")]\n  pub workspace_id: Uuid,\n\n  pub uid: i64,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct AFDatabaseRowDocumentCollabExistenceInfo {\n  pub exists: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct AFCollabEmbedInfo {\n  pub object_id: Uuid,\n  /// The timestamp when the object's embeddings updated\n  pub indexed_at: DateTime<Utc>,\n  /// The timestamp when the object's data updated\n  pub updated_at: DateTime<Utc>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct RepeatedAFCollabEmbedInfo(pub Vec<AFCollabEmbedInfo>);\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct PublishInfo {\n  pub namespace: String,\n  pub publish_name: String,\n  pub view_id: Uuid,\n  #[serde(default)]\n  pub publisher_email: String,\n  #[serde(default)]\n  pub publish_timestamp: DateTime<Utc>,\n  #[serde(default)]\n  pub unpublished_timestamp: Option<DateTime<Utc>>,\n  #[serde(default = \"default_comments_enabled\")]\n  pub comments_enabled: bool,\n  #[serde(default = \"default_duplicate_enabled\")]\n  pub duplicate_enabled: bool,\n}\n\nfn default_comments_enabled() -> bool {\n  true\n}\n\nfn default_duplicate_enabled() -> bool {\n  true\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PublishInfoMeta<Meta> {\n  pub info: PublishInfo,\n  pub meta: Meta,\n}\n\n#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Hash)]\n#[repr(i32)]\npub enum AFRole {\n  Owner = 1,\n  Member = 2,\n  Guest = 3,\n}\n\nimpl AFRole {\n  /// The user can create a [Collab] if the user is [AFRole::Owner] or [AFRole::Member] of the workspace.\n  pub fn can_create_collab(&self) -> bool {\n    matches!(self, AFRole::Owner | AFRole::Member)\n  }\n}\n\nimpl From<i32> for AFRole {\n  fn from(value: i32) -> Self {\n    // Can't modify the value of the enum\n    match value {\n      1 => AFRole::Owner,\n      2 => AFRole::Member,\n      3 => AFRole::Guest,\n      _ => {\n        error!(\"Invalid role id: {}\", value);\n        AFRole::Guest\n      },\n    }\n  }\n}\n\nimpl From<&str> for AFRole {\n  fn from(value: &str) -> Self {\n    match i32::from_str(value) {\n      Ok(value) => value.into(),\n      Err(_) => AFRole::Guest,\n    }\n  }\n}\n\nimpl From<AFRole> for i32 {\n  fn from(role: AFRole) -> Self {\n    role as i32\n  }\n}\n\nimpl From<&AFRole> for i32 {\n  fn from(role: &AFRole) -> Self {\n    role.clone() as i32\n  }\n}\n\nimpl PartialOrd for AFRole {\n  fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n    Some(self.cmp(other))\n  }\n}\n\nimpl Ord for AFRole {\n  fn cmp(&self, other: &Self) -> Ordering {\n    let left = i32::from(self);\n    let right = i32::from(other);\n    // lower value has higher priority\n    left.cmp(&right).reverse()\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct AFPermission {\n  /// The permission id\n  pub id: i32,\n  pub name: String,\n  pub access_level: AFAccessLevel,\n  pub description: String,\n}\n\n#[derive(Deserialize_repr, Serialize_repr, Eq, PartialEq, Debug, Clone, Copy)]\n#[repr(i32)]\npub enum AFAccessLevel {\n  // Can't modify the value of the enum\n  ReadOnly = 10,\n  ReadAndComment = 20,\n  ReadAndWrite = 30,\n  FullAccess = 50,\n}\n\nimpl AFAccessLevel {\n  pub fn can_write(&self) -> bool {\n    match self {\n      AFAccessLevel::ReadOnly | AFAccessLevel::ReadAndComment => false,\n      AFAccessLevel::ReadAndWrite | AFAccessLevel::FullAccess => true,\n    }\n  }\n\n  pub fn can_delete(&self) -> bool {\n    match self {\n      AFAccessLevel::ReadOnly | AFAccessLevel::ReadAndComment | AFAccessLevel::ReadAndWrite => {\n        false\n      },\n      AFAccessLevel::FullAccess => true,\n    }\n  }\n}\n\nimpl From<&AFRole> for AFAccessLevel {\n  fn from(value: &AFRole) -> Self {\n    match value {\n      AFRole::Owner => AFAccessLevel::FullAccess,\n      AFRole::Member => AFAccessLevel::ReadAndWrite,\n      AFRole::Guest => AFAccessLevel::ReadOnly,\n    }\n  }\n}\n\nimpl From<i32> for AFAccessLevel {\n  fn from(value: i32) -> Self {\n    // Can't modify the value of the enum\n    match value {\n      10 => AFAccessLevel::ReadOnly,\n      20 => AFAccessLevel::ReadAndComment,\n      30 => AFAccessLevel::ReadAndWrite,\n      50 => AFAccessLevel::FullAccess,\n      _ => {\n        error!(\"Invalid access level: {}\", value);\n        AFAccessLevel::ReadOnly\n      },\n    }\n  }\n}\n\nimpl From<&str> for AFAccessLevel {\n  fn from(value: &str) -> Self {\n    match i32::from_str(value) {\n      Ok(value) => AFAccessLevel::from(value),\n      Err(_) => AFAccessLevel::ReadOnly,\n    }\n  }\n}\n\nimpl From<AFAccessLevel> for i32 {\n  fn from(level: AFAccessLevel) -> Self {\n    level as i32\n  }\n}\n\nimpl From<&AFAccessLevel> for i32 {\n  fn from(level: &AFAccessLevel) -> Self {\n    *level as i32\n  }\n}\n\nimpl PartialOrd for AFAccessLevel {\n  fn partial_cmp(&self, other: &Self) -> Option<Ordering> {\n    Some(self.cmp(other))\n  }\n}\n\nimpl Ord for AFAccessLevel {\n  fn cmp(&self, other: &Self) -> Ordering {\n    let left = i32::from(self);\n    let right = i32::from(other);\n    left.cmp(&right)\n  }\n}\n\npub type RawData = Vec<u8>;\n\n#[derive(Serialize, Deserialize)]\npub struct AFUserProfile {\n  pub uid: i64,\n  pub uuid: Uuid,\n  pub email: Option<String>,\n  pub password: Option<String>,\n  pub name: Option<String>,\n  pub metadata: Option<serde_json::Value>,\n  pub encryption_sign: Option<String>,\n  pub latest_workspace_id: Uuid,\n  pub updated_at: i64,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct AFUserWithAvatar {\n  pub uuid: Uuid,\n  pub email: String,\n  pub name: String,\n  pub avatar_url: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct AFWorkspace {\n  pub workspace_id: Uuid,\n  pub database_storage_id: Uuid,\n  pub owner_uid: i64,\n  pub owner_name: String,\n  #[serde(default)]\n  pub owner_email: String,\n  pub workspace_type: i32,\n  pub workspace_name: String,\n  pub created_at: DateTime<Utc>,\n  pub icon: String,\n  pub member_count: Option<i64>,\n  #[serde(default)]\n  pub role: Option<AFRole>, // role of the user requesting the workspace\n}\n\n#[derive(Serialize, Deserialize)]\npub struct AFWorkspaceSettings {\n  #[serde(default)]\n  pub disable_search_indexing: bool,\n\n  #[serde(default)]\n  pub ai_model: String,\n}\n\nimpl Default for AFWorkspaceSettings {\n  fn default() -> Self {\n    Self {\n      disable_search_indexing: false,\n      ai_model: \"Auto\".to_string(),\n    }\n  }\n}\n\n#[derive(Default, Serialize, Deserialize, Debug)]\npub struct AFWorkspaceSettingsChange {\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub disable_search_indexing: Option<bool>,\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub ai_model: Option<String>,\n}\n\nimpl AFWorkspaceSettingsChange {\n  pub fn new() -> Self {\n    Self {\n      disable_search_indexing: None,\n      ai_model: None,\n    }\n  }\n  pub fn disable_search_indexing(mut self, disable_search_indexing: bool) -> Self {\n    self.disable_search_indexing = Some(disable_search_indexing);\n    self\n  }\n  pub fn ai_model(mut self, ai_model: String) -> Self {\n    self.ai_model = Some(ai_model);\n    self\n  }\n}\n\n#[derive(Serialize, Deserialize)]\npub struct AFUserWorkspaceInfo {\n  pub user_profile: AFUserProfile,\n  pub visiting_workspace: AFWorkspace,\n  pub workspaces: Vec<AFWorkspace>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Hash, Eq, PartialEq)]\npub struct AFWorkspaceMember {\n  pub name: String,\n  pub email: String,\n  pub role: AFRole,\n  pub avatar_url: Option<String>,\n  pub joined_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Deserialize, Serialize, Debug)]\npub struct AFWorkspaceInvitation {\n  pub invite_id: Uuid,\n  pub workspace_id: Uuid,\n  pub workspace_name: Option<String>,\n  pub inviter_email: Option<String>,\n  pub inviter_name: Option<String>,\n  pub status: AFWorkspaceInvitationStatus,\n  pub updated_at: DateTime<Utc>,\n  pub inviter_icon: Option<String>,\n  pub workspace_icon: String,\n  pub member_count: Option<i64>, // use unwrap_or(0) to get the value\n}\n\n#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)]\n#[repr(i16)]\npub enum AFWorkspaceInvitationStatus {\n  Pending = 0,\n  Accepted = 1,\n  Rejected = 2,\n}\n\nimpl From<i16> for AFWorkspaceInvitationStatus {\n  fn from(value: i16) -> Self {\n    match value {\n      0 => AFWorkspaceInvitationStatus::Pending,\n      1 => AFWorkspaceInvitationStatus::Accepted,\n      2 => AFWorkspaceInvitationStatus::Rejected,\n      _ => {\n        error!(\"Invalid role id: {}\", value);\n        AFWorkspaceInvitationStatus::Pending\n      },\n    }\n  }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct AFCollabEmbeddedChunk {\n  pub fragment_id: String,\n  #[serde(with = \"uuid_str\")]\n  pub object_id: Uuid,\n  pub content_type: EmbeddingContentType,\n  pub content: Option<String>,\n  /// The semantic embedding vector for the content.\n  /// - Defaults to `None`.\n  /// - Will remain `None` if `content` is missing.\n  pub embedding: Option<Vec<f32>>,\n  pub metadata: serde_json::Value,\n  pub fragment_index: i32,\n  pub embedded_type: i16,\n}\n\nimpl AFCollabEmbeddedChunk {\n  pub fn mark_as_duplicate(&mut self) {\n    self.embedding = None;\n    self.content = None;\n  }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct AFCollabEmbeddings {\n  pub tokens_consumed: u32,\n  pub chunks: Vec<AFCollabEmbeddedChunk>,\n}\n\n/// Type of content stored by the embedding.\n/// Currently only plain text of the document is supported.\n/// In the future, we might support other kinds like i.e. PDF, images or image-extracted text.\n#[repr(i32)]\n#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, Eq, PartialEq)]\npub enum EmbeddingContentType {\n  /// The plain text representation of the document.\n  PlainText = 0,\n}\n\nimpl EmbeddingContentType {\n  pub fn from_proto(proto: proto::EmbeddingContentType) -> Result<Self, EntityError> {\n    match proto {\n      proto::EmbeddingContentType::PlainText => Ok(EmbeddingContentType::PlainText),\n      proto::EmbeddingContentType::Unknown => Err(InvalidData(format!(\n        \"{} is not a supported embedding type\",\n        proto.as_str_name()\n      ))),\n    }\n  }\n\n  pub fn to_proto(&self) -> proto::EmbeddingContentType {\n    match self {\n      EmbeddingContentType::PlainText => proto::EmbeddingContentType::PlainText,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct PublishCollabMetadata<Metadata> {\n  pub view_id: uuid::Uuid,\n  pub publish_name: String,\n  pub metadata: Metadata,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct PublishCollabKey {\n  pub workspace_id: uuid::Uuid,\n  pub view_id: uuid::Uuid,\n}\n\n#[derive(Debug)]\npub struct PublishCollabItem<Meta, Data> {\n  pub meta: PublishCollabMetadata<Meta>,\n  pub data: Data,\n  pub comments_enabled: bool,\n  pub duplicate_enabled: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PatchPublishedCollab {\n  pub view_id: Uuid,\n  pub publish_name: Option<String>,\n  pub comments_enabled: Option<bool>,\n  pub duplicate_enabled: Option<bool>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GlobalComments {\n  pub comments: Vec<GlobalComment>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct AFWebUser {\n  pub uuid: Uuid,\n  pub name: String,\n  pub avatar_url: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct AFWebUserWithObfuscatedName {\n  pub uuid: Uuid,\n  pub name: String,\n  pub avatar_url: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GlobalComment {\n  pub user: Option<AFWebUserWithObfuscatedName>,\n  pub created_at: DateTime<Utc>,\n  pub last_updated_at: DateTime<Utc>,\n  pub content: String,\n  pub reply_comment_id: Option<Uuid>,\n  pub comment_id: Uuid,\n  pub is_deleted: bool,\n  pub can_be_deleted: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateGlobalCommentParams {\n  pub content: String,\n  pub reply_comment_id: Option<Uuid>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct DeleteGlobalCommentParams {\n  pub comment_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct Reactions {\n  pub reactions: Vec<Reaction>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct Reaction {\n  pub reaction_type: String,\n  pub react_users: Vec<AFWebUserWithObfuscatedName>,\n  pub comment_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GetReactionQueryParams {\n  pub comment_id: Option<Uuid>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateReactionParams {\n  pub reaction_type: String,\n  pub comment_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct DeleteReactionParams {\n  pub reaction_type: String,\n  pub comment_id: Uuid,\n}\n\n/// Indexing status of a document.\n#[derive(Debug, Clone, Copy, Eq, PartialEq)]\npub enum IndexingStatus {\n  /// Indexing is disabled for that document.\n  Disabled,\n  /// Indexing is enabled, but the document has never been indexed.\n  NotIndexed,\n  /// Indexing is enabled and the document has been indexed.\n  Indexed,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateCategories {\n  pub categories: Vec<TemplateCategory>,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Copy, Clone)]\n#[repr(i32)]\npub enum TemplateCategoryType {\n  UseCase = 0,\n  Feature = 1,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateCategory {\n  pub id: Uuid,\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n  pub description: String,\n  pub category_type: TemplateCategoryType,\n  pub priority: i32,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateCategoryMinimal {\n  pub id: Uuid,\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateTemplateCategoryParams {\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n  pub description: String,\n  pub category_type: TemplateCategoryType,\n  pub priority: i32,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GetTemplateCategoriesQueryParams {\n  pub name_contains: Option<String>,\n  pub category_type: Option<TemplateCategoryType>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UpdateTemplateCategoryParams {\n  pub name: String,\n  pub icon: String,\n  pub bg_color: String,\n  pub description: String,\n  pub category_type: TemplateCategoryType,\n  pub priority: i32,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateCreators {\n  pub creators: Vec<TemplateCreator>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct AccountLink {\n  pub link_type: String,\n  pub url: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateCreator {\n  pub id: Uuid,\n  pub name: String,\n  pub avatar_url: String,\n  pub account_links: Vec<AccountLink>,\n  pub number_of_templates: i32,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateCreatorMinimal {\n  pub id: Uuid,\n  pub name: String,\n  pub avatar_url: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateTemplateCreatorParams {\n  pub name: String,\n  pub avatar_url: String,\n  pub account_links: Vec<AccountLink>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UpdateTemplateCreatorParams {\n  pub name: String,\n  pub avatar_url: String,\n  pub account_links: Vec<AccountLink>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GetTemplateCreatorsQueryParams {\n  pub name_contains: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct Template {\n  pub view_id: Uuid,\n  pub created_at: DateTime<Utc>,\n  pub last_updated_at: DateTime<Utc>,\n  pub name: String,\n  pub description: String,\n  pub about: String,\n  pub view_url: String,\n  pub categories: Vec<TemplateCategory>,\n  pub creator: TemplateCreator,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n  pub related_templates: Vec<TemplateMinimal>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateWithPublishInfo {\n  #[serde(flatten)]\n  pub template: Template,\n  pub publish_info: PublishInfo,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateMinimal {\n  pub view_id: Uuid,\n  pub created_at: DateTime<Utc>,\n  pub last_updated_at: DateTime<Utc>,\n  pub name: String,\n  pub description: String,\n  pub view_url: String,\n  pub creator: TemplateCreatorMinimal,\n  pub categories: Vec<TemplateCategoryMinimal>,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct TemplateMinimalWithPublishInfo {\n  #[serde(flatten)]\n  pub template: TemplateMinimal,\n  pub publish_info: PublishInfo,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct Templates {\n  pub templates: Vec<TemplateMinimalWithPublishInfo>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateTemplateParams {\n  pub view_id: Uuid,\n  pub name: String,\n  pub description: String,\n  pub about: String,\n  pub view_url: String,\n  pub category_ids: Vec<Uuid>,\n  pub creator_id: Uuid,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n  pub related_view_ids: Vec<Uuid>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UpdateTemplateParams {\n  pub name: String,\n  pub description: String,\n  pub about: String,\n  pub view_url: String,\n  pub category_ids: Vec<Uuid>,\n  pub creator_id: Uuid,\n  pub is_new_template: bool,\n  pub is_featured: bool,\n  pub related_view_ids: Vec<Uuid>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GetTemplatesQueryParams {\n  pub category_id: Option<Uuid>,\n  pub is_featured: Option<bool>,\n  pub is_new_template: Option<bool>,\n  pub name_contains: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateGroup {\n  pub category: TemplateCategoryMinimal,\n  pub templates: Vec<TemplateMinimal>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateGroupWithPublishInfo {\n  pub category: TemplateCategoryMinimal,\n  pub templates: Vec<TemplateMinimalWithPublishInfo>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateHomePage {\n  pub featured_templates: Vec<TemplateMinimalWithPublishInfo>,\n  pub new_templates: Vec<TemplateMinimalWithPublishInfo>,\n  pub template_groups: Vec<TemplateGroupWithPublishInfo>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct TemplateHomePageQueryParams {\n  pub per_count: Option<i64>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AvatarImageSource {\n  pub file_id: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UserImageAssetSource {\n  pub file_id: String,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct UserImageAssetContent {\n  pub data: Vec<u8>,\n  pub content_type: String,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct AvatarContent {\n  pub data: Vec<u8>,\n  pub content_type: String,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug, Copy, Clone)]\n#[repr(i32)]\npub enum AccessRequestStatus {\n  Pending = 0,\n  Approved = 1,\n  Rejected = 2,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AccessRequestWithViewId {\n  pub request_id: Uuid,\n  pub workspace: AFWorkspace,\n  pub requester: AccessRequesterInfo,\n  pub view_id: Uuid,\n  pub status: AccessRequestStatus,\n  pub created_at: DateTime<Utc>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AccessRequesterInfo {\n  pub uid: i64,\n  pub uuid: Uuid,\n  pub email: String,\n  pub name: String,\n  pub avatar_url: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AccessRequestMinimal {\n  pub request_id: Uuid,\n  pub workspace_id: Uuid,\n  pub requester_id: Uuid,\n  pub view_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct CreateAccessRequestParams {\n  pub workspace_id: Uuid,\n  pub view_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct ApproveAccessRequestParams {\n  pub is_approved: bool,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct CreateImportTask {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub workspace_name: String,\n  pub content_length: u64,\n}\n\n/// Create a import task\n/// Upload the import zip file to the presigned url\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreateImportTaskResponse {\n  pub task_id: String,\n  pub presigned_url: String,\n}\n\n#[derive(Debug)]\npub struct WorkspaceNamespace {\n  pub workspace_id: Uuid,\n  pub namespace: String,\n  pub is_original: bool,\n}\n\n#[derive(Clone, Serialize, Deserialize, Debug)]\npub struct QuickNote {\n  pub id: Uuid,\n  pub data: serde_json::Value,\n  pub created_at: DateTime<Utc>,\n  pub last_updated_at: DateTime<Utc>,\n}\n\n#[derive(Clone, Serialize, Deserialize, Debug)]\npub struct QuickNotes {\n  pub quick_notes: Vec<QuickNote>,\n  pub has_more: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateQuickNoteParams {\n  pub data: Option<serde_json::Value>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UpdateQuickNoteParams {\n  pub data: serde_json::Value,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct ListQuickNotesQueryParams {\n  pub search_term: Option<String>,\n  pub offset: Option<i32>,\n  pub limit: Option<i32>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct WorkspaceInviteCodeParams {\n  pub validity_period_hours: Option<i64>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct WorkspaceInviteToken {\n  pub code: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct InvitedWorkspace {\n  pub workspace_id: Uuid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GetInvitationCodeInfoQuery {\n  pub code: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct InvitationCodeInfo {\n  pub workspace_id: Uuid,\n  pub workspace_name: String,\n  pub owner_avatar: Option<String>,\n  pub owner_name: String,\n  pub workspace_icon_url: Option<String>,\n  pub is_member: Option<bool>,\n  pub member_count: i64,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct JoinWorkspaceByInviteCodeParams {\n  pub code: String,\n}\n\npub struct MentionableWorkspaceMemberOrGuest {\n  pub uuid: Uuid,\n  pub name: String,\n  pub email: String,\n  pub role: AFRole,\n  pub avatar_url: Option<String>,\n  pub cover_image_url: Option<String>,\n  pub custom_image_url: Option<String>,\n  pub description: Option<String>,\n}\n\nimpl From<MentionableWorkspaceMemberOrGuest> for MentionablePerson {\n  fn from(val: MentionableWorkspaceMemberOrGuest) -> Self {\n    MentionablePerson {\n      uuid: val.uuid,\n      name: val.name,\n      email: val.email,\n      role: match val.role {\n        AFRole::Owner => MentionablePersonType::WorkspaceMember,\n        AFRole::Member => MentionablePersonType::WorkspaceMember,\n        AFRole::Guest => MentionablePersonType::WorkspaceGuest,\n      },\n      avatar_url: val.avatar_url,\n      cover_image_url: val.cover_image_url,\n      custom_image_url: val.custom_image_url,\n      description: val.description,\n      invited: false,\n    }\n  }\n}\n\npub struct MentionableWorkspaceMemberOrGuestWithLastMentionedTime {\n  pub uuid: Uuid,\n  pub name: String,\n  pub email: String,\n  pub role: AFRole,\n  pub avatar_url: Option<String>,\n  pub cover_image_url: Option<String>,\n  pub custom_image_url: Option<String>,\n  pub description: Option<String>,\n  pub last_mentioned_at: Option<DateTime<Utc>>,\n}\n\nimpl From<MentionableWorkspaceMemberOrGuestWithLastMentionedTime>\n  for MentionablePersonWithLastMentionedTime\n{\n  fn from(val: MentionableWorkspaceMemberOrGuestWithLastMentionedTime) -> Self {\n    MentionablePersonWithLastMentionedTime {\n      uuid: val.uuid,\n      name: val.name,\n      email: val.email,\n      role: match val.role {\n        AFRole::Owner => MentionablePersonType::WorkspaceMember,\n        AFRole::Member => MentionablePersonType::WorkspaceMember,\n        AFRole::Guest => MentionablePersonType::WorkspaceGuest,\n      },\n      avatar_url: val.avatar_url,\n      cover_image_url: val.cover_image_url,\n      custom_image_url: val.custom_image_url,\n      description: val.description,\n      invited: false,\n      last_mentioned_at: val.last_mentioned_at,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct WorkspaceMemberProfile {\n  pub name: String,\n  pub avatar_url: Option<String>,\n  pub cover_image_url: Option<String>,\n  pub custom_image_url: Option<String>,\n  pub description: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MentionablePerson {\n  pub uuid: Uuid,\n  pub name: String,\n  pub email: String,\n  pub role: MentionablePersonType,\n  pub avatar_url: Option<String>,\n  pub cover_image_url: Option<String>,\n  pub custom_image_url: Option<String>,\n  pub description: Option<String>,\n  pub invited: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MentionablePersonWithLastMentionedTime {\n  pub uuid: Uuid,\n  pub name: String,\n  pub email: String,\n  pub role: MentionablePersonType,\n  pub avatar_url: Option<String>,\n  pub cover_image_url: Option<String>,\n  pub custom_image_url: Option<String>,\n  pub description: Option<String>,\n  pub invited: bool,\n  pub last_mentioned_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MentionablePersonWithAccess {\n  #[serde(flatten)]\n  pub person: MentionablePerson,\n  pub can_access_page: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MentionablePersons {\n  pub persons: Vec<MentionablePersonWithLastMentionedTime>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct MentionablePersonsWithAccess {\n  pub persons: Vec<MentionablePersonWithAccess>,\n}\n\n#[derive(Serialize_repr, Deserialize_repr, Debug)]\n#[repr(u8)]\npub enum MentionablePersonType {\n  WorkspaceMember = 1,\n  WorkspaceGuest = 2,\n  Contact = 3,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct PageMentionUpdate {\n  pub person_id: Uuid,\n  pub block_id: Option<String>,\n  pub require_notification: bool,\n  // Client to provide view name as, at the time that the mention is created,\n  // the view might not have been in sync with the server side copy of the folder collab.\n  // In addition, we want to capture the view name at the time of the mention creation, in case\n  // it gets modified/deleted afterwards.\n  pub view_name: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct PageMentionNotification {\n  pub workspace_name: String,\n  pub workspace_id: Uuid,\n  pub view_id: Uuid,\n  pub view_name: String,\n  pub mentioner_name: String,\n  pub mentioner_avatar_url: Option<String>,\n  pub mentioned_at: DateTime<Utc>,\n  pub mentioned_person_id: Uuid,\n  pub mentioned_person_name: String,\n  pub mentioned_person_email: String,\n  pub block_id: Option<String>,\n}\n\npub struct ProcessedPageMentionNotification {\n  pub view_id: Uuid,\n  pub person_id: Uuid,\n}\n\n#[cfg(test)]\nmod test {\n  use crate::dto::{CreateCollabData, CreateCollabDataV0};\n\n  use bytes::Bytes;\n  use collab_entity::CollabType;\n\n  use uuid::Uuid;\n\n  #[test]\n  fn collab_params_serialization_from_old_format() {\n    let v0 = CreateCollabDataV0 {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Document,\n      encoded_collab_v1: vec![\n        7, 0, 0, 0, 0, 0, 0, 0, 1, 209, 196, 206, 243, 15, 1, 26, 4, 0, 0, 0, 0, 0, 0, 1, 1, 209,\n        196, 206, 243, 15, 0, 40, 1, 4, 100, 97, 116, 97, 5, 116, 105, 116, 108, 101, 1, 119, 128,\n        8, 120, 118, 88, 114, 83, 79, 105, 70, 69, 84, 70, 97, 66, 79, 57, 53, 111, 122, 87, 110,\n        54, 106, 71, 87, 66, 104, 120, 114, 79, 70, 74, 111, 109, 119, 68, 119, 114, 114, 89, 66,\n        103, 79, 72, 114, 102, 51, 87, 55, 79, 76, 110, 85, 120, 69, 113, 121, 104, 121, 107, 82,\n        117, 82, 113, 73, 104, 89, 72, 84, 114, 105, 122, 56, 122, 72, 90, 67, 110, 97, 120, 83,\n        114, 85, 113, 71, 98, 52, 50, 110, 87, 116, 105, 55, 107, 82, 103, 72, 68, 101, 89, 118,\n        82, 112, 114, 73, 122, 87, 76, 68, 120, 101, 57, 55, 87, 68, 77, 113, 107, 120, 104, 65,\n        71, 103, 48, 51, 49, 110, 119, 111, 106, 70, 108, 114, 102, 83, 99, 89, 110, 73, 50, 98,\n        118, 54, 68, 88, 111, 118, 74, 107, 121, 119, 103, 102, 98, 107, 51, 78, 99, 103, 51, 73,\n        78, 106, 67, 85, 97, 54, 114, 50, 103, 83, 55, 70, 57, 122, 106, 115, 103, 121, 88, 68, 97,\n        101, 50, 68, 84, 70, 84, 72, 87, 112, 105, 102, 71, 108, 52, 48, 114, 106, 113, 56, 119,\n        110, 86, 71, 54, 65, 99, 99, 109, 85, 107, 105, 89, 86, 100, 77, 75, 69, 73, 54, 107, 122,\n        48, 80, 54, 50, 99, 80, 100, 115, 78, 90, 49, 70, 90, 106, 117, 106, 98, 111, 88, 65, 105,\n        83, 108, 82, 105, 90, 73, 73, 90, 102, 116, 77, 117, 79, 81, 79, 85, 53, 71, 72, 65, 49,\n        119, 118, 88, 97, 98, 122, 52, 122, 77, 70, 85, 112, 100, 115, 67, 89, 107, 114, 88, 87,\n        101, 65, 79, 86, 102, 77, 102, 106, 100, 117, 74, 57, 89, 82, 65, 103, 72, 100, 120, 89,\n        75, 54, 103, 70, 89, 122, 75, 122, 53, 119, 78, 76, 83, 68, 90, 101, 115, 109, 116, 117,\n        65, 54, 53, 48, 97, 52, 85, 51, 57, 111, 74, 90, 73, 77, 117, 105, 80, 116, 57, 70, 84,\n        118, 76, 122, 111, 82, 116, 68, 51, 83, 71, 108, 78, 77, 71, 102, 68, 84, 110, 114, 80,\n        106, 79, 65, 75, 114, 118, 116, 98, 57, 72, 84, 108, 101, 76, 109, 48, 110, 54, 102, 97,\n        83, 89, 77, 66, 88, 69, 119, 78, 71, 89, 75, 53, 114, 80, 56, 72, 55, 122, 112, 116, 82,\n        52, 117, 121, 113, 67, 53, 72, 48, 101, 83, 81, 76, 76, 87, 110, 53, 81, 106, 67, 100, 103,\n        55, 85, 109, 115, 103, 55, 110, 121, 87, 121, 117, 98, 72, 121, 85, 106, 57, 66, 90, 89,\n        54, 50, 122, 84, 69, 103, 57, 52, 67, 102, 50, 82, 114, 74, 84, 115, 87, 97, 87, 113, 109,\n        88, 105, 113, 97, 100, 113, 57, 87, 114, 70, 57, 120, 108, 80, 122, 52, 113, 119, 53, 48,\n        69, 73, 78, 90, 55, 120, 65, 67, 122, 89, 111, 84, 57, 88, 79, 112, 105, 76, 76, 51, 77,\n        52, 119, 84, 98, 67, 54, 101, 105, 89, 72, 80, 99, 119, 90, 109, 56, 105, 49, 68, 57, 102,\n        111, 75, 65, 68, 74, 87, 106, 69, 65, 85, 71, 104, 85, 75, 117, 66, 70, 105, 72, 100, 75,\n        121, 86, 74, 81, 56, 49, 73, 82, 85, 98, 120, 122, 74, 88, 107, 116, 110, 73, 101, 75, 87,\n        57, 76, 53, 120, 100, 117, 112, 99, 72, 49, 105, 122, 115, 113, 103, 109, 85, 122, 113, 50,\n        70, 76, 118, 76, 121, 88, 55, 110, 84, 78, 120, 99, 78, 70, 122, 117, 66, 98, 65, 75, 112,\n        50, 116, 112, 84, 73, 113, 106, 77, 106, 68, 114, 99, 76, 78, 53, 109, 117, 66, 88, 100,\n        68, 76, 77, 113, 67, 101, 108, 120, 49, 117, 50, 56, 89, 118, 88, 82, 74, 55, 99, 111, 78,\n        77, 77, 87, 111, 121, 50, 52, 73, 116, 120, 73, 81, 72, 53, 107, 70, 50, 111, 67, 97, 122,\n        88, 103, 114, 76, 97, 105, 109, 113, 105, 89, 77, 79, 70, 74, 90, 70, 122, 117, 100, 105,\n        113, 121, 55, 55, 89, 49, 85, 51, 103, 120, 98, 79, 80, 121, 66, 57, 73, 98, 114, 70, 100,\n        113, 83, 101, 106, 65, 82, 121, 49, 83, 73, 122, 84, 80, 103, 121, 84, 110, 117, 74, 117,\n        70, 90, 116, 104, 56, 57, 109, 82, 71, 100, 68, 106, 70, 75, 80, 83, 77, 110, 113, 120,\n        102, 68, 119, 118, 109, 84, 113, 77, 79, 119, 112, 114, 107, 118, 85, 78, 65, 104, 106, 98,\n        53, 81, 98, 80, 71, 122, 107, 52, 83, 67, 121, 68, 78, 108, 107, 55, 109, 121, 105, 51,\n        120, 100, 110, 51, 108, 122, 65, 117, 77, 83, 78, 55, 121, 86, 76, 109, 78, 103, 88, 68,\n        75, 48, 81, 101, 120, 112, 69, 78, 86, 88, 116, 78, 48, 97, 105, 55, 116, 79, 104, 118, 86,\n        89, 101, 74, 112, 82, 87, 82, 115, 84, 54, 55, 97, 111, 76, 56, 109, 120, 53, 73, 55, 85,\n        114, 82, 49, 90, 66, 76, 76, 99, 81, 72, 49, 105, 71, 79, 109, 110, 87, 90, 68, 111, 75,\n        108, 116, 86, 81, 103, 109, 112, 120, 100, 52, 70, 79, 111, 121, 57, 111, 76, 65, 111, 83,\n        67, 56, 48, 120, 53, 87, 56, 72, 83, 83, 51, 103, 114, 49, 88, 85, 53, 111, 51, 90, 86,\n        121, 121, 86, 74, 116, 112, 104, 116, 120, 84, 113, 85, 71, 90, 75, 85, 86, 119, 122, 75,\n        49, 122, 78, 101, 118, 82, 54, 90, 55, 72, 114, 53, 84, 72, 115, 102, 104, 48, 103, 48, 99,\n        65, 101, 65, 111, 122, 89, 106, 89, 117, 49, 110, 79, 115, 78, 80, 71, 53, 107, 112, 54,\n        48, 121, 55, 119, 116, 119, 76, 73, 105, 110, 102, 79, 101, 72, 82, 105, 80, 117, 80, 88,\n        70, 84, 51, 53, 98, 108, 74, 84, 121, 112, 76, 76, 98, 119, 102, 80, 122, 51, 120, 53, 54,\n        113, 68, 0, 0,\n      ],\n    };\n    let data = bincode::serialize(&v0).unwrap();\n    let collab_params = CreateCollabData::from_bytes(&data).unwrap();\n    assert_eq!(collab_params.object_id, v0.object_id);\n    assert_eq!(collab_params.collab_type, v0.collab_type);\n    assert_eq!(collab_params.encoded_collab_v1, v0.encoded_collab_v1);\n  }\n\n  #[test]\n  fn deserialization_using_protobuf() {\n    let collab_params_with_embeddings = CreateCollabData {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Document,\n      encoded_collab_v1: Bytes::default(),\n    };\n\n    let protobuf_encoded = collab_params_with_embeddings.to_protobuf_bytes();\n    let collab_params_decoded = CreateCollabData::from_protobuf_bytes(&protobuf_encoded).unwrap();\n    assert_eq!(collab_params_with_embeddings, collab_params_decoded);\n  }\n\n  #[test]\n  fn deserialize_collab_params_without_embeddings() {\n    let collab_params = CreateCollabData {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Document,\n      encoded_collab_v1: Bytes::from(vec![1, 2, 3]),\n    };\n\n    let protobuf_encoded = collab_params.to_protobuf_bytes();\n    let collab_params_decoded = CreateCollabData::from_protobuf_bytes(&protobuf_encoded).unwrap();\n    assert_eq!(collab_params, collab_params_decoded);\n  }\n}\n"
  },
  {
    "path": "libs/database-entity/src/error.rs",
    "content": "#[derive(Debug, thiserror::Error)]\npub enum EntityError {\n  #[error(\"Invalid data: {0}\")]\n  InvalidData(String),\n  #[error(\"Deserialization error: {0}\")]\n  DeserializationError(String),\n  #[error(\"Serialization error: {0}\")]\n  SerializationError(String),\n}\n"
  },
  {
    "path": "libs/database-entity/src/file_dto.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::fmt::Display;\n\n#[derive(Serialize, Deserialize)]\npub struct CreateUploadRequest {\n  pub file_id: String,\n  pub parent_dir: String,\n  pub content_type: String,\n  #[serde(default)]\n  pub file_size: Option<u64>,\n}\n\nimpl Display for CreateUploadRequest {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"CreateUploadRequest: file_id: {}, content_type: {}\",\n      self.file_id, self.content_type\n    )\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct CreateUploadResponse {\n  pub file_id: String,\n  pub upload_id: String,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct UploadPartData {\n  pub file_id: String,\n  pub upload_id: String,\n  pub part_number: i32,\n  pub body: Vec<u8>,\n}\n\nimpl Display for UploadPartData {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"UploadPartRequest: file_id: {}, upload_id: {}, part_number: {}, size:{}\",\n      self.file_id,\n      self.upload_id,\n      self.part_number,\n      self.body.len()\n    )\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct UploadPartResponse {\n  pub e_tag: String,\n  pub part_num: i32,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct CompleteUploadRequest {\n  pub file_id: String,\n  pub parent_dir: String,\n  pub upload_id: String,\n  pub parts: Vec<CompletedPartRequest>,\n}\n\nimpl Display for CompleteUploadRequest {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"CompleteUploadRequest: file_id: {}, upload_id: {}, parts: {}\",\n      self.file_id,\n      self.upload_id,\n      self.parts.len()\n    )\n  }\n}\n\n#[derive(Serialize, Deserialize)]\npub struct CompletedPartRequest {\n  pub e_tag: String,\n  pub part_number: i32,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct CompleteUploadResponse {\n  pub file_id: String,\n  pub upload_id: String,\n  pub parts: Vec<CompletedPartRequest>,\n}\n"
  },
  {
    "path": "libs/database-entity/src/lib.rs",
    "content": "pub mod dto;\npub mod error;\npub mod file_dto;\n"
  },
  {
    "path": "libs/gotrue/Cargo.toml",
    "content": "[package]\nname = \"gotrue\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nserde.workspace = true\nserde_json.workspace = true\nanyhow.workspace = true\nreqwest = { workspace = true, features = [\"json\", \"rustls-tls\", \"cookies\"] }\ninfra = { path = \"../infra\", features = [\"request_util\"] }\ngotrue-entity = { path = \"../gotrue-entity\" }\ntracing = \"0.1\"\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\ngetrandom = { version = \"0.2\", features = [\"js\"] }\n"
  },
  {
    "path": "libs/gotrue/src/api.rs",
    "content": "use std::time::Duration;\n\nuse super::grant::Grant;\nuse crate::params::{\n  AdminDeleteUserParams, AdminUserParams, CreateSSOProviderParams, GenerateLinkParams,\n  GenerateLinkResponse, InviteUserParams, MagicLinkParams, RecoverParams, VerifyParams,\n};\nuse anyhow::Context;\nuse gotrue_entity::dto::{\n  AdminListUsersResponse, AuthProvider, GoTrueSettings, GotrueTokenResponse, SignUpResponse,\n  UpdateGotrueUserParams, User,\n};\nuse gotrue_entity::error::{GoTrueError, GoTrueErrorSerde, GotrueClientError};\nuse gotrue_entity::sso::{SSOProvider, SSOProviders};\nuse infra::reqwest::{check_response, from_body, from_response};\nuse reqwest::{Method, RequestBuilder};\nuse tracing::event;\n\n#[derive(Clone, Debug)]\npub struct Client {\n  client: reqwest::Client,\n  pub base_url: String,\n}\n\nimpl Client {\n  pub fn new(client: reqwest::Client, base_url: &str) -> Self {\n    Self {\n      client,\n      base_url: base_url.to_owned(),\n    }\n  }\n\n  pub fn oauth_url(&self, provider: &AuthProvider) -> String {\n    format!(\"{}/authorize?provider={}\", self.base_url, provider.as_str())\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn health(&self) -> Result<(), GoTrueError> {\n    let url: String = format!(\"{}/health\", self.base_url);\n    let resp = self\n      .client\n      .get(&url)\n      .timeout(Duration::from_secs(5))\n      .send()\n      .await\n      .context(format!(\"calling {} failed\", url))?;\n    Ok(check_response(resp).await?)\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn settings(&self) -> Result<GoTrueSettings, GoTrueError> {\n    let url: String = format!(\"{}/settings\", self.base_url);\n    let resp = self.client.get(&url).send().await?;\n    let settings: GoTrueSettings = from_response(resp).await?;\n    Ok(settings)\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn sign_up(\n    &self,\n    email: &str,\n    password: &str,\n    redirect_to: Option<&str>,\n  ) -> Result<SignUpResponse, GoTrueError> {\n    let payload = serde_json::json!({\n        \"email\": email,\n        \"password\": password,\n    });\n    let url: String = format!(\"{}/signup\", self.base_url);\n    let mut req_builder = self.client.post(&url).json(&payload);\n    if let Some(redirect_to) = redirect_to {\n      req_builder = req_builder.header(\"redirect_to\", redirect_to);\n    }\n\n    let resp = req_builder.send().await?;\n    to_gotrue_result(resp).await\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn token(&self, grant: &Grant) -> Result<GotrueTokenResponse, GoTrueError> {\n    // https://github.com/supabase/auth/blob/master/internal/api/verify.go#L219\n    let url = format!(\"{}/token?grant_type={}\", self.base_url, grant.type_as_str());\n    let payload = grant.json_value();\n    let resp = self.client.post(url).json(&payload).send().await?;\n    if resp.status().is_success() {\n      let token: GotrueTokenResponse = from_body(resp).await?;\n      Ok(token)\n    } else if resp.status().is_client_error() {\n      Err(from_body::<GotrueClientError>(resp).await?.into())\n    } else {\n      Err(anyhow::anyhow!(\"unexpected response status: {}\", resp.status()).into())\n    }\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn verify(\n    &self,\n    verify_params: &VerifyParams,\n  ) -> Result<GotrueTokenResponse, GoTrueError> {\n    let url = format!(\"{}/verify\", self.base_url);\n    let resp = self.client.post(url).json(verify_params).send().await?;\n    if resp.status().is_success() {\n      let token: GotrueTokenResponse = from_body(resp).await?;\n      Ok(token)\n    } else if resp.status().is_client_error() {\n      Err(from_body::<GotrueClientError>(resp).await?.into())\n    } else {\n      Err(anyhow::anyhow!(\"unexpected response status: {}\", resp.status()).into())\n    }\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn recover(\n    &self,\n    recover_params: &RecoverParams,\n  ) -> Result<GotrueTokenResponse, GoTrueError> {\n    let url = format!(\"{}/recover\", self.base_url);\n    let resp = self.client.post(url).json(recover_params).send().await?;\n    if resp.status().is_success() {\n      let token: GotrueTokenResponse = from_body(resp).await?;\n      Ok(token)\n    } else if resp.status().is_client_error() {\n      Err(from_body::<GotrueClientError>(resp).await?.into())\n    } else {\n      Err(anyhow::anyhow!(\"unexpected response status: {}\", resp.status()).into())\n    }\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn logout(&self, access_token: &str) -> Result<(), GoTrueError> {\n    let url = format!(\"{}/logout\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url, access_token)\n      .send()\n      .await?;\n    Ok(check_response(resp).await?)\n  }\n\n  #[tracing::instrument(skip_all, err)]\n  pub async fn user_info(&self, access_token: &str) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/user\", self.base_url);\n    match self\n      .http_client_with_auth(Method::GET, &url, access_token)\n      .send()\n      .await\n    {\n      Ok(resp) => to_gotrue_result(resp).await,\n      Err(err) => {\n        event!(\n          tracing::Level::ERROR,\n          \"fail to get user info with access token: {}\",\n          access_token\n        );\n        Err(err.into())\n      },\n    }\n  }\n\n  pub async fn update_user(\n    &self,\n    access_token: &str,\n    update_user_params: &UpdateGotrueUserParams,\n  ) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/user\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url, access_token)\n      .json(update_user_params)\n      .send()\n      .await?;\n\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_list_user(\n    &self,\n    access_token: &str,\n    filter: Option<&str>,\n  ) -> Result<AdminListUsersResponse, GoTrueError> {\n    let url = format!(\"{}/admin/users\", self.base_url);\n    let mut req = self.http_client_with_auth(Method::GET, &url, access_token);\n    if let Some(filter) = filter {\n      req = req.query(&[(\"filter\", filter)]);\n    }\n    let resp = req.send().await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_user_details(\n    &self,\n    access_token: &str,\n    user_id: &str, // uuid\n  ) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/admin/users/{}\", self.base_url, user_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url, access_token)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_delete_user(\n    &self,\n    access_token: &str,\n    user_uuid: &str,\n    delete_user_params: &AdminDeleteUserParams,\n  ) -> Result<(), GoTrueError> {\n    let url = format!(\"{}/admin/users/{}\", self.base_url, user_uuid);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url, access_token)\n      .json(&delete_user_params)\n      .send()\n      .await?;\n    check_gotrue_result(resp).await\n  }\n\n  pub async fn admin_update_user(\n    &self,\n    access_token: &str,\n    user_uuid: &str,\n    admin_user_params: &AdminUserParams,\n  ) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/admin/users/{}\", self.base_url, user_uuid);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url, access_token)\n      .json(&admin_user_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_invite_user(\n    &self,\n    access_token: &str,\n    admin_user_params: &InviteUserParams,\n  ) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/invite\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url, access_token)\n      .json(&admin_user_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_add_user(\n    &self,\n    access_token: &str,\n    admin_user_params: &AdminUserParams,\n  ) -> Result<User, GoTrueError> {\n    let url = format!(\"{}/admin/users\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url, access_token)\n      .json(&admin_user_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_generate_link(\n    &self,\n    access_token: &str,\n    generate_link_params: &GenerateLinkParams,\n  ) -> Result<GenerateLinkResponse, GoTrueError> {\n    let url = format!(\"{}/admin/generate_link\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url, access_token)\n      .json(&generate_link_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn magic_link(\n    &self,\n    magic_link_params: &MagicLinkParams,\n    redirect_to: Option<String>,\n  ) -> Result<(), GoTrueError> {\n    let url = format!(\"{}/magiclink\", self.base_url);\n    let mut req_builder = self.client.request(Method::POST, &url);\n    if let Some(redirect_to) = redirect_to {\n      req_builder = req_builder.header(\"redirect_to\", redirect_to);\n    }\n    let resp = req_builder.json(&magic_link_params).send().await?;\n    check_gotrue_result(resp).await\n  }\n\n  pub async fn admin_list_sso_providers(\n    &self,\n    access_token: &str,\n  ) -> Result<SSOProviders, GoTrueError> {\n    let url = format!(\"{}/admin/sso/providers\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url, access_token)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_create_sso_providers(\n    &self,\n    access_token: &str,\n    create_sso_provider_params: &CreateSSOProviderParams,\n  ) -> Result<SSOProvider, GoTrueError> {\n    let url = format!(\"{}/admin/sso/providers\", self.base_url);\n    let resp = self\n      .http_client_with_auth(Method::POST, &url, access_token)\n      .json(create_sso_provider_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_get_sso_provider(\n    &self,\n    access_token: &str,\n    idp_id: &str,\n  ) -> Result<SSOProvider, GoTrueError> {\n    let url = format!(\"{}/admin/sso/providers/{}\", self.base_url, idp_id);\n    let resp = self\n      .http_client_with_auth(Method::GET, &url, access_token)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_update_sso_provider(\n    &self,\n    access_token: &str,\n    idp_id: &str,\n    create_sso_provider_params: &CreateSSOProviderParams,\n  ) -> Result<SSOProvider, GoTrueError> {\n    let url = format!(\"{}/admin/sso/providers/{}\", self.base_url, idp_id);\n    let resp = self\n      .http_client_with_auth(Method::PUT, &url, access_token)\n      .json(create_sso_provider_params)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub async fn admin_delete_sso_provider(\n    &self,\n    access_token: &str,\n    idp_id: &str,\n  ) -> Result<SSOProvider, GoTrueError> {\n    let url = format!(\"{}/admin/sso/providers/{}\", self.base_url, idp_id);\n    let resp = self\n      .http_client_with_auth(Method::DELETE, &url, access_token)\n      .send()\n      .await?;\n    to_gotrue_result(resp).await\n  }\n\n  pub fn http_client_with_auth(\n    &self,\n    method: Method,\n    url: &str,\n    access_token: &str,\n  ) -> RequestBuilder {\n    self.client.request(method, url).bearer_auth(access_token)\n  }\n}\n\nasync fn to_gotrue_result<T>(resp: reqwest::Response) -> Result<T, GoTrueError>\nwhere\n  T: serde::de::DeserializeOwned,\n{\n  if resp.status().is_success() {\n    let t: T = from_body(resp).await?;\n    Ok(t)\n  } else {\n    let err: GoTrueErrorSerde = from_body(resp).await?;\n    Err(GoTrueError::Internal(err))\n  }\n}\n\nasync fn check_gotrue_result(resp: reqwest::Response) -> Result<(), GoTrueError> {\n  if resp.status().is_success() {\n    Ok(())\n  } else {\n    let err: GoTrueErrorSerde = from_body(resp).await?;\n    Err(GoTrueError::Internal(err))\n  }\n}\n"
  },
  {
    "path": "libs/gotrue/src/grant.rs",
    "content": "use tracing::warn;\n\npub enum Grant {\n  Password(PasswordGrant),\n  RefreshToken(RefreshTokenGrant),\n  IdToken,\n  PKCE,\n}\n\npub struct PasswordGrant {\n  pub email: String,\n  pub password: String,\n}\n\npub struct RefreshTokenGrant {\n  pub refresh_token: String,\n}\n\nimpl Grant {\n  pub fn type_as_str(&self) -> &str {\n    match self {\n      Grant::Password(_) => \"password\",\n      Grant::RefreshToken(_) => \"refresh_token\",\n      Grant::IdToken => \"id_token\",\n      Grant::PKCE => \"password\",\n    }\n  }\n\n  pub fn json_value(&self) -> serde_json::Value {\n    match self {\n      Grant::Password(p) => {\n        serde_json::json!({\n            \"email\": p.email,\n            \"password\": p.password,\n        })\n      },\n      Grant::RefreshToken(r) => {\n        serde_json::json!({\n            \"refresh_token\": r.refresh_token,\n        })\n      },\n      Grant::IdToken => {\n        warn!(\"id_token grant is not supported\");\n        serde_json::json!({ \"msg\": \"id_token grant is not supported\"})\n      },\n      Grant::PKCE => {\n        warn!(\"pcke grant is not supported\");\n        serde_json::json!({ \"msg\": \"pcke grant is not supported\"})\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "libs/gotrue/src/lib.rs",
    "content": "pub mod api;\npub mod grant;\npub mod params;\n"
  },
  {
    "path": "libs/gotrue/src/params.rs",
    "content": "use std::collections::btree_map::BTreeMap;\n\nuse gotrue_entity::dto::{Factor, Identity};\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct AdminDeleteUserParams {\n  pub should_soft_delete: bool,\n}\n\n#[derive(Default, Serialize)]\npub struct InviteUserParams {\n  pub email: String,\n  pub data: serde_json::Value,\n}\n\n#[derive(Debug, Default, Deserialize, Serialize)]\npub struct AdminUserParams {\n  pub aud: String,\n  pub role: String,\n  pub email: String,\n  pub phone: String,\n  pub password: Option<String>,\n  pub email_confirm: bool,\n  pub phone_confirm: bool,\n  pub user_metadata: BTreeMap<String, serde_json::Value>,\n  pub app_metadata: BTreeMap<String, serde_json::Value>,\n  pub ban_duration: String,\n}\n\n#[derive(Deserialize, Serialize)]\npub struct GenerateLinkParams {\n  #[serde(rename = \"type\")]\n  pub type_: GenerateLinkType,\n\n  pub email: String,\n  pub new_email: String,\n  pub password: String,\n  pub data: BTreeMap<String, serde_json::Value>,\n  pub redirect_to: String,\n}\n\n#[derive(Default, Deserialize, Serialize)]\npub struct MagicLinkParams {\n  pub email: String,\n  pub data: BTreeMap<String, serde_json::Value>,\n  pub code_challenge_method: String,\n  pub code_challenge: String,\n}\n\nimpl Default for GenerateLinkParams {\n  fn default() -> Self {\n    GenerateLinkParams {\n      type_: GenerateLinkType::MagicLink,\n      email: String::default(),\n      new_email: String::default(),\n      password: String::default(),\n      data: BTreeMap::new(),\n      redirect_to: \"appflowy-flutter://\".to_string(),\n    }\n  }\n}\n\n#[derive(Deserialize, Serialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum GenerateLinkType {\n  MagicLink,\n  Recovery,\n  Invite,\n  Signup,\n  EmailChange,\n  PhoneChange,\n  Reauthenticate,\n  Sms,\n  Email,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct GenerateLinkResponse {\n  // putting User here as Rust does not support struct field extension\n  // use gotrue_entity::User\n  pub id: String,\n  pub aud: String,\n  pub role: String,\n  pub email: String,\n  pub email_confirmed_at: Option<String>,\n  pub invited_at: Option<String>,\n  pub phone: String,\n  pub phone_confirmed_at: Option<String>,\n  pub confirmation_sent_at: Option<String>,\n  pub confirmed_at: Option<String>,\n  pub recovery_sent_at: Option<String>,\n  pub new_email: Option<String>,\n  pub email_change_sent_at: Option<String>,\n  pub new_phone: Option<String>,\n  pub phone_change_sent_at: Option<String>,\n  pub reauthentication_sent_at: Option<String>,\n  pub last_sign_in_at: Option<String>,\n  pub app_metadata: serde_json::Value,\n  pub user_metadata: serde_json::Value,\n  pub factors: Option<Vec<Factor>>,\n  pub identities: Vec<Identity>,\n  pub created_at: String,\n  pub updated_at: String,\n  pub banned_until: Option<String>,\n  pub deleted_at: Option<String>,\n  //\n  pub action_link: String,\n  pub email_otp: String,\n  pub hashed_token: String,\n  pub verification_type: String,\n  pub redirect_to: String,\n}\n\n#[derive(Debug, Serialize, Default)]\npub struct CreateSSOProviderParams {\n  #[serde(rename = \"type\")]\n  pub type_: String,\n  pub metadata_url: String,\n  pub metadata_xml: String,\n  pub domains: Vec<String>,\n  pub attribute_mapping: serde_json::Value,\n}\n\n#[derive(Deserialize, Serialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum VerifyType {\n  Recovery,\n  MagicLink,\n}\n\n#[derive(Deserialize, Serialize)]\npub struct VerifyParams {\n  #[serde(rename = \"type\")]\n  pub type_: VerifyType,\n  pub email: String,\n  pub token: String,\n}\n\n#[derive(Deserialize, Serialize)]\npub struct RecoverParams {\n  pub email: String,\n}\n"
  },
  {
    "path": "libs/gotrue-entity/Cargo.toml",
    "content": "[package]\nname = \"gotrue-entity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nserde.workspace = true\nserde_json.workspace = true\nlazy_static = \"1.4.0\"\n# can not upgrade to 9.3.1, it's not campatible with gotrue token\njsonwebtoken = \"8.3.0\"\napp-error = { workspace = true, features = [\"gotrue_error\"] }\n"
  },
  {
    "path": "libs/gotrue-entity/src/dto.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::collections::BTreeMap;\nuse std::fmt::{Display, Formatter};\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct Identity {\n  pub id: String,\n  pub user_id: String,\n  pub identity_data: Option<serde_json::Value>,\n  pub provider: String,\n  pub last_sign_in_at: String,\n  pub created_at: String,\n  pub updated_at: String,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AdminListUsersResponse {\n  pub users: Vec<User>,\n  pub aud: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct User {\n  pub id: String,\n\n  pub aud: String,\n  pub role: String,\n  pub email: String,\n\n  pub email_confirmed_at: Option<String>,\n  pub invited_at: Option<String>,\n\n  pub phone: String,\n  pub phone_confirmed_at: Option<String>,\n\n  pub confirmation_sent_at: Option<String>,\n\n  // For backward compatibility only. Use EmailConfirmedAt or PhoneConfirmedAt instead.\n  pub confirmed_at: Option<String>,\n\n  pub recovery_sent_at: Option<String>,\n\n  pub new_email: Option<String>,\n  pub email_change_sent_at: Option<String>,\n\n  pub new_phone: Option<String>,\n  pub phone_change_sent_at: Option<String>,\n\n  pub reauthentication_sent_at: Option<String>,\n\n  pub last_sign_in_at: Option<String>,\n\n  pub app_metadata: serde_json::Value,\n  pub user_metadata: serde_json::Value,\n\n  pub factors: Option<Vec<Factor>>,\n  pub identities: Option<Vec<Identity>>,\n\n  pub created_at: String,\n  pub updated_at: String,\n  pub banned_until: Option<String>,\n  pub deleted_at: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct Factor {\n  pub id: String,\n  pub created_at: String,\n  pub updated_at: String,\n  pub status: String,\n  pub friendly_name: Option<String>,\n  pub factor_type: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct GotrueTokenResponse {\n  /// the token that clients use to make authenticated requests to the server or API. It is a bearer token that provides temporary, secure access to server resources.\n  pub access_token: String,\n  pub token_type: String,\n  /// the access_token will remain valid before it expires and needs to be refreshed.\n  pub expires_in: i64,\n  /// a timestamp in seconds indicating the exact time at which the access_token will expire.\n  pub expires_at: i64,\n  /// The refresh token is used to obtain a new access_token once the current access_token expires.\n  /// Refresh tokens are usually long-lived and are stored securely by the client.\n  pub refresh_token: String,\n  pub user: User,\n  pub provider_access_token: Option<String>,\n  pub provider_refresh_token: Option<String>,\n}\n\nimpl Display for GotrueTokenResponse {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.debug_struct(\"GotrueTokenResponse\")\n      .field(\"expires_at\", &self.expires_at)\n      .field(\"token_type\", &self.token_type)\n      .finish()\n  }\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GoTrueSettings {\n  pub external: GoTrueOAuthProviderSettings,\n  pub disable_signup: bool,\n  pub mailer_autoconfirm: bool,\n  pub phone_autoconfirm: bool,\n  pub sms_provider: String,\n  pub mfa_enabled: Option<bool>, // later version have this field removed\n  pub saml_enabled: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct GoTrueOAuthProviderSettings(BTreeMap<String, bool>);\n\nimpl GoTrueOAuthProviderSettings {\n  pub fn has_provider(&self, p: &AuthProvider) -> bool {\n    let a = self.0.get(p.as_str());\n    match a {\n      Some(v) => *v,\n      None => false,\n    }\n  }\n\n  pub fn oauth_providers(&self) -> Vec<&str> {\n    self\n      .0\n      .iter()\n      .filter(|&(key, &value)| value && key != \"email\" && key != \"phone\")\n      .map(|(key, _value)| key.as_str())\n      .collect()\n  }\n}\n\npub enum AuthProvider {\n  // Non-OAuth providers\n  Email,\n  Phone,\n\n  // OAuth providers\n  Apple,\n  Azure,\n  Bitbucket,\n  Discord,\n  Facebook,\n  Figma,\n  Github,\n  Gitlab,\n  Google,\n  Keycloak,\n  Kakao,\n  Linkedin,\n  Notion,\n  Spotify,\n  Slack,\n  Workos,\n  Twitch,\n  Twitter,\n  Zoom,\n}\n\nimpl AuthProvider {\n  pub fn as_str(&self) -> &str {\n    match self {\n      AuthProvider::Apple => \"apple\",\n      AuthProvider::Azure => \"azure\",\n      AuthProvider::Bitbucket => \"bitbucket\",\n      AuthProvider::Discord => \"discord\",\n      AuthProvider::Facebook => \"facebook\",\n      AuthProvider::Figma => \"figma\",\n      AuthProvider::Github => \"github\",\n      AuthProvider::Gitlab => \"gitlab\",\n      AuthProvider::Google => \"google\",\n      AuthProvider::Keycloak => \"keycloak\",\n      AuthProvider::Kakao => \"kakao\",\n      AuthProvider::Linkedin => \"linkedin\",\n      AuthProvider::Notion => \"notion\",\n      AuthProvider::Spotify => \"spotify\",\n      AuthProvider::Slack => \"slack\",\n      AuthProvider::Workos => \"workos\",\n      AuthProvider::Twitch => \"twitch\",\n      AuthProvider::Twitter => \"twitter\",\n      AuthProvider::Email => \"email\",\n      AuthProvider::Phone => \"phone\",\n      AuthProvider::Zoom => \"zoom\",\n    }\n  }\n}\n\nimpl AuthProvider {\n  pub fn from<A: AsRef<str>>(value: A) -> Option<AuthProvider> {\n    match value.as_ref() {\n      \"apple\" => Some(AuthProvider::Apple),\n      \"azure\" => Some(AuthProvider::Azure),\n      \"bitbucket\" => Some(AuthProvider::Bitbucket),\n      \"discord\" => Some(AuthProvider::Discord),\n      \"facebook\" => Some(AuthProvider::Facebook),\n      \"figma\" => Some(AuthProvider::Figma),\n      \"github\" => Some(AuthProvider::Github),\n      \"gitlab\" => Some(AuthProvider::Gitlab),\n      \"google\" => Some(AuthProvider::Google),\n      \"keycloak\" => Some(AuthProvider::Keycloak),\n      \"kakao\" => Some(AuthProvider::Kakao),\n      \"linkedin\" => Some(AuthProvider::Linkedin),\n      \"notion\" => Some(AuthProvider::Notion),\n      \"spotify\" => Some(AuthProvider::Spotify),\n      \"slack\" => Some(AuthProvider::Slack),\n      \"workos\" => Some(AuthProvider::Workos),\n      \"twitch\" => Some(AuthProvider::Twitch),\n      \"twitter\" => Some(AuthProvider::Twitter),\n      \"email\" => Some(AuthProvider::Email),\n      \"phone\" => Some(AuthProvider::Phone),\n      \"zoom\" => Some(AuthProvider::Zoom),\n      _ => None,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize)]\npub struct OAuthURL {\n  pub url: String,\n}\n#[derive(Serialize, Deserialize, Debug)]\n#[serde(untagged)]\npub enum SignUpResponse {\n  Authenticated(GotrueTokenResponse),\n  NotAuthenticated(User),\n}\n#[derive(Default, Serialize, Deserialize)]\npub struct UpdateGotrueUserParams {\n  pub email: String,\n  pub password: Option<String>,\n  pub nonce: String,\n  pub data: BTreeMap<String, serde_json::Value>,\n  pub app_metadata: Option<BTreeMap<String, serde_json::Value>>,\n  pub phone: String,\n  pub channel: String,\n  pub code_challenge: String,\n  pub code_challenge_method: String,\n}\n\nimpl UpdateGotrueUserParams {\n  pub fn new() -> Self {\n    Self::default()\n  }\n\n  pub fn with_opt_email<T: ToString>(mut self, email: Option<T>) -> Self {\n    self.email = email.map(|v| v.to_string()).unwrap_or_default();\n    self\n  }\n\n  pub fn with_opt_password<T: ToString>(mut self, password: Option<T>) -> Self {\n    self.password = password.map(|v| v.to_string());\n    self\n  }\n}\n"
  },
  {
    "path": "libs/gotrue-entity/src/gotrue_jwt.rs",
    "content": "use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};\nuse serde::{Deserialize, Serialize};\nuse std::fmt::{Display, Formatter};\n\n#[derive(Debug, Serialize)]\npub struct GoTrueServiceRoleClaims {\n  pub role: String,\n}\n\nimpl GoTrueServiceRoleClaims {\n  pub fn encode(&self, jwt_secret: &[u8]) -> Result<String, jsonwebtoken::errors::Error> {\n    jsonwebtoken::encode(\n      &jsonwebtoken::Header::default(),\n      &self,\n      &jsonwebtoken::EncodingKey::from_secret(jwt_secret),\n    )\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct GoTrueJWTClaims {\n  // JWT standard claims\n  pub aud: Option<String>,\n  pub exp: Option<i64>,\n  pub jti: Option<String>,\n  pub iat: Option<i64>,\n  pub iss: Option<String>,\n  pub nbf: Option<i64>,\n  pub sub: Option<String>,\n\n  pub email: String,\n  pub phone: String,\n  pub app_metadata: serde_json::Value,\n  pub user_metadata: serde_json::Value,\n  pub role: String,\n  pub aal: Option<String>,\n  pub amr: Option<Vec<Amr>>,\n  pub session_id: Option<String>,\n}\n\nimpl Display for GoTrueJWTClaims {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.debug_struct(\"GoTrueJWTClaims\")\n      .field(\"exp\", &self.exp)\n      .field(\"email\", &self.email)\n      .finish()\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct Amr {\n  pub method: String,\n  pub timestamp: u64,\n  pub provider: Option<String>,\n}\n\nlazy_static::lazy_static! {\n  pub static ref VALIDATION: Validation = Validation::new(Algorithm::HS256);\n}\n\nimpl GoTrueJWTClaims {\n  pub fn decode(token: &str, secret: &[u8]) -> Result<Self, jsonwebtoken::errors::Error> {\n    let token_data = decode::<Self>(token, &DecodingKey::from_secret(secret), &VALIDATION)?;\n    Ok(token_data.claims)\n  }\n}\n"
  },
  {
    "path": "libs/gotrue-entity/src/lib.rs",
    "content": "pub mod dto;\npub mod error {\n  pub use app_error::gotrue::*;\n}\npub mod gotrue_jwt;\npub mod sso;\n"
  },
  {
    "path": "libs/gotrue-entity/src/sso.rs",
    "content": "use std::collections::BTreeMap;\n\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Deserialize)]\npub struct SSOProviders {\n  pub items: Option<Vec<SSOProvider>>,\n}\n\n#[derive(Debug, Deserialize)]\npub struct SSOProvider {\n  pub id: String,\n  pub saml: SAMLProvider,\n  pub domains: Vec<String>,\n  pub created_at: String,\n  pub updated_at: String,\n}\n\n#[derive(Debug, Deserialize)]\npub struct SAMLProvider {\n  pub entity_id: String,\n  pub metadata_xml: Option<String>,\n  pub metadata_url: Option<String>,\n  pub attribute_mapping: SAMLAttributeMapping,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SAMLAttributeMapping {\n  pub keys: Option<BTreeMap<String, SAMLAttribute>>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SAMLAttribute {\n  pub name: Option<String>,\n  pub names: Option<Vec<String>>,\n  pub default: serde_json::Value,\n}\n\npub struct SSODomain {\n  pub domain: String,\n}\n"
  },
  {
    "path": "libs/indexer/Cargo.toml",
    "content": "[package]\nname = \"indexer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nrayon.workspace = true\ntiktoken-rs = \"0.6.0\"\napp-error = { workspace = true }\nappflowy-ai-client = { workspace = true, features = [\"client-api\"] }\ncollab = { workspace = true }\ncollab-entity = { workspace = true }\ncollab-document = { workspace = true }\ndatabase-entity.workspace = true\ndatabase.workspace = true\nfutures-util.workspace = true\nsqlx.workspace = true\ntokio.workspace = true\ntracing.workspace = true\nthiserror = \"1.0.56\"\nuuid.workspace = true\nasync-trait.workspace = true\nserde_json.workspace = true\nanyhow.workspace = true\ninfra = { workspace = true }\nprometheus-client = \"0.22.3\"\nchrono = \"0.4.39\"\nserde.workspace = true\nredis = { workspace = true, features = [\n  \"aio\",\n  \"tokio-comp\",\n  \"connection-manager\",\n] }\ntwox-hash = { version = \"2.1.0\", features = [\"xxhash64\"] }\ntext-splitter = { version = \"0.25.1\" }\nasync-openai.workspace = true\n"
  },
  {
    "path": "libs/indexer/README.md",
    "content": "## Embedding Architecture (Do not edit, AI generated)\n\nThe indexing system consists of several interconnected components:\n\n```mermaid\ngraph TD\n    A[Document Content] --> B[Indexer]\n    B --> C[Text Chunking]\n    C --> D[Embedding Generation]\n    D --> E[Database Storage]\n    F[Scheduler] --> B\n    G[Environment Configuration] --> F\n    H[Redis Queue] <--> F\n    I[IndexerProvider] --> B\n```\n\n### Key Components\n\n1. **Indexer**: Interface that defines methods for creating and embedding content chunks\n2. **DocumentIndexer**: Implementation of Indexer specifically for document content\n3. **IndexerProvider**: Factory that resolves the appropriate Indexer based on content type\n4. **Scheduler**: Manages embedding tasks and coordinates the embedding process\n5. **AFEmbedder**: Provides embedding capabilities through OpenAI or Azure OpenAI APIs\n\n## Embedding Process Flow\n\n### 1. Content Preparation\n\nThere are multiple paths that can trigger embedding generation in the system. Each path ultimately processes document\ncontent, but they differ in how they're initiated and handled:\n\n```mermaid\nflowchart TD\n    A[Document Creation/Update] --> B{Embedding Path}\n    B -->|Immediate Indexing| C[index_collab_immediately]\n    B -->|Background Indexing| D[index_pending_collab_one/index_pending_collabs]\n    B -->|Redis Stream| E[read_background_embed_tasks]\n    B -->|Batch Processing| F[index_workspace]\n    C --> G[embed_immediately]\n    D --> H[embed_in_background]\n    E --> I[generate_embeddings_loop]\n    F --> J[index_then_write_embedding_to_disk]\n    G --> K[Indexer Processing]\n    H --> L[Redis Queue]\n    I --> K\n    J --> K\n    L --> I\n    K --> M[create_embedded_chunks_from_collab/Text]\n    M --> N[embed]\n    N --> O[Database Storage]\n```\n\n#### Path 1: Immediate Indexing\n\nWhen a document is created or updated and requires immediate indexing:\n\n```mermaid\nsequenceDiagram\n    participant App as AppFlowy\n    participant Scheduler as IndexerScheduler\n    participant Indexer as DocumentIndexer\n    participant Embedder as AFEmbedder\n    participant DB as Database\n    App ->> Scheduler: index_collab_immediately(workspace_id, object_id, collab, collab_type)\n    Scheduler ->> Scheduler: embed_immediately(UnindexedCollabTask)\n    Scheduler ->> Indexer: create_embedded_chunks_from_text(object_id, paragraphs, model)\n    Indexer ->> Indexer: Split text into chunks\n    Indexer ->> Embedder: embed(chunks)\n    Embedder ->> DB: Store embeddings via batch_insert_records\n```\n\n#### Path 2: Background Indexing\n\nFor non-urgent indexing, tasks are queued for background processing:\n\n```mermaid\nsequenceDiagram\n    participant App as AppFlowy\n    participant Scheduler as IndexerScheduler\n    participant Redis as Redis Queue\n    participant Worker as Background Worker\n    participant Indexer as DocumentIndexer\n    participant DB as Database\n    App ->> Scheduler: index_pending_collab_one/index_pending_collabs\n    Scheduler ->> Scheduler: embed_in_background(tasks)\n    Scheduler ->> Redis: add_background_embed_task(tasks)\n    Worker ->> Redis: read_background_embed_tasks()\n    Worker ->> Worker: generate_embeddings_loop\n    Worker ->> Indexer: create_embedded_chunks_from_text\n    Indexer ->> Worker: Return chunks\n    Worker ->> DB: batch_insert_records\n```\n\n#### Path 3: Batch Processing\n\nFor processing multiple unindexed documents at once, typically used for initial indexing or catch-up processing:\n\n```mermaid\nsequenceDiagram\n    participant System as System Process\n    participant IndexProcess as index_workspace\n    participant Storage as CollabStorage\n    participant Indexer as DocumentIndexer\n    participant DB as Database\n    System ->> IndexProcess: index_workspace(scheduler, workspace_id)\n    IndexProcess ->> DB: stream_unindexed_collabs\n    IndexProcess ->> Storage: get_encode_collab\n    IndexProcess ->> IndexProcess: index_then_write_embedding_to_disk\n    IndexProcess ->> Indexer: create_embeddings(embedder, provider, collabs)\n    Indexer ->> DB: batch_insert_records\n```\n\nIn all these paths, the content goes through similar processing steps:\n\n1. Document content extraction (paragraphs from document)\n2. Text chunking (grouping paragraphs into manageable chunks)\n3. Embedding generation via the AI service\n4. Storage in the database\n\n### 2. Chunking Strategy\n\nDocuments are broken into manageable chunks for effective embedding:\n\n1. The system extracts paragraphs from the document\n2. Paragraphs are grouped into chunks of approximately 8000 characters\n3. A consistent hash is generated for each chunk to avoid duplicate processing\n4. Each chunk is prepared as an `AFCollabEmbeddedChunk` with metadata\n\n```mermaid\ngraph LR\n    A[Full Document] --> B[Extract Paragraphs]\n    B --> C[Group Paragraphs]\n    C --> D[Generate Content Hash]\n    D --> E[Create Embedded Chunks]\n```\n\n### 3. Embedding Generation\n\nThe actual embedding creation happens via OpenAI or Azure's API:\n\n1. Chunks are sent to the embedding service (OpenAI or Azure)\n2. The API returns vectors for each chunk\n3. Vectors are associated with their original chunks\n4. Complete embeddings are stored in the database\n\n## Technical Implementation\n\n### Fragment Processing\n\n```rust\nfn split_text_into_chunks(\n    object_id: Uuid,\n    paragraphs: Vec<String>,\n    collab_type: CollabType,\n    embedding_model: EmbeddingModel,\n) -> Result<Vec<AFCollabEmbeddedChunk>, AppError> {\n    // Group paragraphs into chunks of roughly 8000 characters\n    let split_contents = group_paragraphs_by_max_content_len(paragraphs, 8000);\n\n    // Create metadata for the chunks\n    let metadata = json!({\n      \"id\": object_id,\n      \"source\": \"appflowy\",\n      \"name\": \"document\",\n      \"collab_type\": collab_type\n  });\n\n    // Track seen fragments to avoid duplicates\n    let mut seen = std::collections::HashSet::new();\n    let mut chunks = Vec::new();\n\n    // Process each content chunk\n    for (index, content) in split_contents.into_iter().enumerate() {\n        // Generate consistent hash for deduplication\n        let consistent_hash = Hasher::oneshot(0, content.as_bytes());\n        let fragment_id = format!(\"{:x}\", consistent_hash);\n\n        // Only add new fragments\n        if seen.insert(fragment_id.clone()) {\n            chunks.push(AFCollabEmbeddedChunk {\n                fragment_id,\n                object_id,\n                content_type: EmbeddingContentType::PlainText,\n                content: Some(content),\n                embedding: None,\n                metadata: metadata.clone(),\n                fragment_index: index as i32,\n                embedded_type: 0,\n            });\n        }\n    }\n\n    Ok(chunks)\n}\n```\n\n## Embedding Storage\n\nEmbeddings are stored in the database with their associated metadata:\n\n```mermaid\nerDiagram\n    COLLAB_EMBEDDING ||--o{ EMBEDDING_FRAGMENT: contains\n    COLLAB_EMBEDDING {\n        uuid object_id\n        uuid workspace_id\n        int collab_type\n        int tokens_used\n        timestamp indexed_at\n    }\n    EMBEDDING_FRAGMENT {\n        string fragment_id\n        uuid object_id\n        string content_type\n        vec embedding\n        json metadata\n        int fragment_index\n    }\n```\n\n## Configuration\n\nThe embedding system is configurable through environment variables:\n\n- `APPFLOWY_INDEXER_ENABLED`: Enable/disable the indexing system (default: `true`)\n- `APPFLOWY_INDEXER_SCHEDULER_NUM_THREAD`: Number of threads for processing (default: `50`)\n- `AI_OPENAI_API_KEY`: OpenAI API key for embeddings\n- `AI_AZURE_OPENAI_API_KEY`, `AI_AZURE_OPENAI_API_BASE`, `AI_AZURE_OPENAI_API_VERSION`: Azure OpenAI configuration\n"
  },
  {
    "path": "libs/indexer/src/collab_indexer/document_indexer.rs",
    "content": "use crate::collab_indexer::Indexer;\nuse crate::vector::embedder::AFEmbedder;\nuse crate::vector::open_ai::group_paragraphs_by_max_content_len;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse async_openai::types::{CreateEmbeddingRequestArgs, EmbeddingInput, EncodingFormat};\nuse async_trait::async_trait;\nuse collab::preclude::Collab;\nuse collab_document::document::DocumentBody;\nuse database_entity::dto::{AFCollabEmbeddedChunk, AFCollabEmbeddings, EmbeddingContentType};\nuse infra::env_util::get_env_var;\nuse serde_json::json;\nuse tracing::{debug, error, trace, warn};\nuse twox_hash::xxhash64::Hasher;\nuse uuid::Uuid;\n\npub struct DocumentIndexer;\n\n#[async_trait]\nimpl Indexer for DocumentIndexer {\n  fn create_embedded_chunks_from_collab(\n    &self,\n    collab: &Collab,\n    model: EmbeddingModel,\n  ) -> Result<Vec<AFCollabEmbeddedChunk>, AppError> {\n    let object_id = collab.object_id().parse()?;\n    let document = DocumentBody::from_collab(collab).ok_or_else(|| {\n      anyhow!(\n        \"Failed to get document body from collab `{}`: schema is missing required fields\",\n        object_id\n      )\n    })?;\n\n    let paragraphs = document.to_plain_text(collab.transact());\n    self.create_embedded_chunks_from_text(object_id, paragraphs, model)\n  }\n\n  fn create_embedded_chunks_from_text(\n    &self,\n    object_id: Uuid,\n    paragraphs: Vec<String>,\n    model: EmbeddingModel,\n  ) -> Result<Vec<AFCollabEmbeddedChunk>, AppError> {\n    if paragraphs.is_empty() {\n      warn!(\n        \"[Embedding] No paragraphs found in document `{}`. Skipping embedding.\",\n        object_id\n      );\n\n      return Ok(vec![]);\n    }\n    // Group paragraphs into chunks of roughly 8000 characters.\n    split_text_into_chunks(\n      object_id,\n      paragraphs,\n      model,\n      get_env_var(\"APPFLOWY_EMBEDDING_CHUNK_SIZE\", \"2000\")\n        .parse::<usize>()\n        .unwrap_or(1000),\n      get_env_var(\"APPFLOWY_EMBEDDING_CHUNK_OVERLAP\", \"200\")\n        .parse::<usize>()\n        .unwrap_or(200),\n    )\n  }\n\n  async fn embed(\n    &self,\n    embedder: &AFEmbedder,\n    mut chunks: Vec<AFCollabEmbeddedChunk>,\n  ) -> Result<Option<AFCollabEmbeddings>, AppError> {\n    let mut valid_indices = Vec::new();\n    for (i, chunk) in chunks.iter().enumerate() {\n      if let Some(ref content) = chunk.content {\n        if !content.is_empty() {\n          valid_indices.push(i);\n        }\n      }\n    }\n\n    if valid_indices.is_empty() {\n      return Ok(None);\n    }\n\n    let mut contents = Vec::with_capacity(valid_indices.len());\n    for &i in &valid_indices {\n      contents.push(chunks[i].content.as_ref().unwrap().to_owned());\n    }\n\n    let request = CreateEmbeddingRequestArgs::default()\n      .model(embedder.model().name())\n      .input(EmbeddingInput::StringArray(contents))\n      .encoding_format(EncodingFormat::Float)\n      .dimensions(EmbeddingModel::default_model().default_dimensions())\n      .build()\n      .map_err(|err| AppError::Unhandled(err.to_string()))?;\n\n    let resp = embedder.async_embed(request).await?;\n    if resp.data.len() != valid_indices.len() {\n      error!(\n        \"[Embedding] requested {} embeddings, received {} embeddings\",\n        valid_indices.len(),\n        resp.data.len()\n      );\n      return Err(AppError::Unhandled(format!(\n        \"Mismatch in number of embeddings requested and received: {} vs {}\",\n        valid_indices.len(),\n        resp.data.len()\n      )));\n    }\n\n    for embedding in resp.data {\n      let chunk_idx = valid_indices[embedding.index as usize];\n      chunks[chunk_idx].embedding = Some(embedding.embedding);\n    }\n\n    Ok(Some(AFCollabEmbeddings {\n      tokens_consumed: resp.usage.total_tokens,\n      chunks,\n    }))\n  }\n}\n\n/// chunk_size:\n/// Small Chunks (50–256 tokens): Best for precision-focused tasks (e.g., Q&A, technical docs) where specific details matter.\n/// Medium Chunks (256–1,024 tokens): Ideal for balanced tasks like RAG or contextual search, providing enough context without noise.\n/// Large Chunks (1,024–2,048 tokens): Suited for analysis or thematic tasks where broad understanding is key.\n///\n/// overlap:\n/// Add 10–20% overlap for larger chunks (e.g., 50–100 tokens for 512-token chunks) to preserve context across boundaries.\npub fn split_text_into_chunks(\n  object_id: Uuid,\n  paragraphs: Vec<String>,\n  embedding_model: EmbeddingModel,\n  chunk_size: usize,\n  overlap: usize,\n) -> Result<Vec<AFCollabEmbeddedChunk>, AppError> {\n  // we only support text embedding 3 small for now\n  debug_assert!(matches!(\n    embedding_model,\n    EmbeddingModel::TextEmbedding3Small\n  ));\n\n  if paragraphs.is_empty() {\n    return Ok(vec![]);\n  }\n\n  trace!(\n    \"[Embedding] Splitting document `{}` into chunks with chunk_size: {}, overlap: {}, paragraphs: {:?}\",\n    object_id,\n    chunk_size,\n    overlap, paragraphs\n  );\n  let split_contents = group_paragraphs_by_max_content_len(paragraphs, chunk_size, overlap);\n  let metadata = json!({\n      \"id\": object_id,\n      \"source\": \"appflowy\",\n      \"name\": \"document\",\n  });\n\n  let mut seen = std::collections::HashSet::new();\n  let mut chunks = Vec::new();\n\n  for (index, content) in split_contents.into_iter().enumerate() {\n    let consistent_hash = Hasher::oneshot(0, content.as_bytes());\n    let fragment_id = format!(\"{:x}\", consistent_hash);\n    if seen.insert(fragment_id.clone()) {\n      chunks.push(AFCollabEmbeddedChunk {\n        fragment_id,\n        object_id,\n        content_type: EmbeddingContentType::PlainText,\n        content: Some(content),\n        embedding: None,\n        metadata: metadata.clone(),\n        fragment_index: index as i32,\n        embedded_type: 0,\n      });\n    } else {\n      debug!(\n        \"[Embedding] Duplicate fragment_id detected: {}. This fragment will not be added.\",\n        fragment_id\n      );\n    }\n  }\n\n  trace!(\n    \"[Embedding] Created {} chunks for object_id `{}`, chunk_size: {}, overlap: {}\",\n    chunks.len(),\n    object_id,\n    chunk_size,\n    overlap\n  );\n  Ok(chunks)\n}\n"
  },
  {
    "path": "libs/indexer/src/collab_indexer/mod.rs",
    "content": "mod document_indexer;\nmod provider;\n\npub use document_indexer::*;\npub use provider::*;\n"
  },
  {
    "path": "libs/indexer/src/collab_indexer/provider.rs",
    "content": "use crate::collab_indexer::DocumentIndexer;\nuse crate::vector::embedder::AFEmbedder;\nuse app_error::AppError;\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse async_trait::async_trait;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse database_entity::dto::{AFCollabEmbeddedChunk, AFCollabEmbeddings};\nuse infra::env_util::get_env_var;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tracing::info;\nuse uuid::Uuid;\n\n#[async_trait]\npub trait Indexer: Send + Sync {\n  fn create_embedded_chunks_from_collab(\n    &self,\n    collab: &Collab,\n    model: EmbeddingModel,\n  ) -> Result<Vec<AFCollabEmbeddedChunk>, AppError>;\n\n  fn create_embedded_chunks_from_text(\n    &self,\n    object_id: Uuid,\n    paragraphs: Vec<String>,\n    model: EmbeddingModel,\n  ) -> Result<Vec<AFCollabEmbeddedChunk>, AppError>;\n\n  async fn embed(\n    &self,\n    embedder: &AFEmbedder,\n    content: Vec<AFCollabEmbeddedChunk>,\n  ) -> Result<Option<AFCollabEmbeddings>, AppError>;\n}\n\n/// A structure responsible for resolving different [Indexer] types for different [CollabType]s,\n/// including access permission checks for the specific workspaces.\npub struct IndexerProvider {\n  indexer_cache: HashMap<CollabType, Arc<dyn Indexer>>,\n}\n\nimpl IndexerProvider {\n  pub fn new() -> Arc<Self> {\n    let mut cache: HashMap<CollabType, Arc<dyn Indexer>> = HashMap::new();\n    let enabled = get_env_var(\"APPFLOWY_INDEXER_ENABLED\", \"true\")\n      .parse::<bool>()\n      .unwrap_or(true);\n\n    info!(\"Indexer is enabled: {}\", enabled);\n    if enabled {\n      cache.insert(CollabType::Document, Arc::new(DocumentIndexer));\n    }\n    Arc::new(Self {\n      indexer_cache: cache,\n    })\n  }\n\n  /// Returns indexer for a specific type of [Collab] object.\n  /// If collab of given type is not supported or workspace it belongs to has indexing disabled,\n  /// returns `None`.\n  pub fn indexer_for(&self, collab_type: CollabType) -> Option<Arc<dyn Indexer>> {\n    self.indexer_cache.get(&collab_type).cloned()\n  }\n\n  pub fn is_indexing_enabled(&self, collab_type: CollabType) -> bool {\n    self.indexer_cache.contains_key(&collab_type)\n  }\n}\n"
  },
  {
    "path": "libs/indexer/src/entity.rs",
    "content": "use collab::entity::EncodedCollab;\nuse collab_entity::CollabType;\nuse database_entity::dto::AFCollabEmbeddedChunk;\nuse uuid::Uuid;\n\npub struct UnindexedCollab {\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n  pub collab: EncodedCollab,\n}\n\npub struct EmbeddingRecord {\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n  pub tokens_used: u32,\n  pub chunks: Vec<AFCollabEmbeddedChunk>,\n}\n\nimpl EmbeddingRecord {\n  pub fn empty(workspace_id: Uuid, object_id: Uuid, collab_type: CollabType) -> Self {\n    Self {\n      workspace_id,\n      object_id,\n      collab_type,\n      tokens_used: 0,\n      chunks: vec![],\n    }\n  }\n}\n"
  },
  {
    "path": "libs/indexer/src/error.rs",
    "content": "#[derive(thiserror::Error, Debug)]\npub enum IndexerError {\n  #[error(\"Redis stream group not exist: {0}\")]\n  StreamGroupNotExist(String),\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n}\n"
  },
  {
    "path": "libs/indexer/src/lib.rs",
    "content": "pub mod collab_indexer;\npub mod entity;\npub mod error;\npub mod metrics;\npub mod queue;\npub mod scheduler;\nmod unindexed_workspace;\npub mod vector;\n"
  },
  {
    "path": "libs/indexer/src/metrics.rs",
    "content": "use prometheus_client::metrics::{counter::Counter, histogram::Histogram};\nuse prometheus_client::registry::Registry;\n#[derive(Clone)]\npub struct EmbeddingMetrics {\n  total_embed_count: Counter,\n  failed_embed_count: Counter,\n  write_embedding_time_histogram: Histogram,\n  gen_embeddings_time_histogram: Histogram,\n  fallback_background_tasks: Counter,\n}\n\nimpl EmbeddingMetrics {\n  fn init() -> Self {\n    Self {\n      total_embed_count: Counter::default(),\n      failed_embed_count: Counter::default(),\n      write_embedding_time_histogram: Histogram::new([500.0, 1000.0, 5000.0, 8000.0].into_iter()),\n      gen_embeddings_time_histogram: Histogram::new([1000.0, 3000.0, 5000.0, 8000.0].into_iter()),\n      fallback_background_tasks: Counter::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::init();\n    let realtime_registry = registry.sub_registry_with_prefix(\"embedding\");\n\n    // Register each metric with the Prometheus registry\n    realtime_registry.register(\n      \"total_embed_count\",\n      \"Total count of embeddings processed\",\n      metrics.total_embed_count.clone(),\n    );\n    realtime_registry.register(\n      \"failed_embed_count\",\n      \"Total count of failed embeddings\",\n      metrics.failed_embed_count.clone(),\n    );\n    realtime_registry.register(\n      \"write_embedding_time_seconds\",\n      \"Histogram of embedding write times\",\n      metrics.write_embedding_time_histogram.clone(),\n    );\n\n    realtime_registry.register(\n      \"gen_embeddings_time_histogram\",\n      \"Histogram of embedding generation times\",\n      metrics.gen_embeddings_time_histogram.clone(),\n    );\n\n    realtime_registry.register(\n      \"fallback_background_tasks\",\n      \"Total count of fallback background tasks\",\n      metrics.fallback_background_tasks.clone(),\n    );\n\n    metrics\n  }\n\n  pub fn record_embed_count(&self, count: u64) {\n    self.total_embed_count.inc_by(count);\n  }\n\n  pub fn record_failed_embed_count(&self, count: u64) {\n    self.failed_embed_count.inc_by(count);\n  }\n\n  pub fn record_fallback_background_tasks(&self, count: u64) {\n    self.fallback_background_tasks.inc_by(count);\n  }\n\n  pub fn record_write_embedding_time(&self, millis: u128) {\n    self.write_embedding_time_histogram.observe(millis as f64);\n  }\n\n  pub fn record_gen_embedding_time(&self, num: u32, millis: u128) {\n    tracing::trace!(\"[Embedding]: index {} collabs cost: {}ms\", num, millis);\n    self.gen_embeddings_time_histogram.observe(millis as f64);\n  }\n}\n"
  },
  {
    "path": "libs/indexer/src/queue.rs",
    "content": "use crate::error::IndexerError;\nuse crate::scheduler::UnindexedCollabTask;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse redis::aio::ConnectionManager;\nuse redis::streams::{StreamId, StreamReadOptions, StreamReadReply};\nuse redis::{AsyncCommands, RedisResult, Value};\nuse serde_json::from_str;\nuse tracing::error;\n\npub const INDEX_TASK_STREAM_NAME: &str = \"index_collab_task_stream\";\nconst INDEXER_WORKER_GROUP_NAME: &str = \"indexer_worker_group\";\nconst INDEXER_CONSUMER_NAME: &str = \"appflowy_worker\";\n\nimpl TryFrom<&StreamId> for UnindexedCollabTask {\n  type Error = IndexerError;\n\n  fn try_from(stream_id: &StreamId) -> Result<Self, Self::Error> {\n    let task_str = match stream_id.map.get(\"task\") {\n      Some(value) => match value {\n        Value::BulkString(data) => String::from_utf8_lossy(data).to_string(),\n        Value::SimpleString(value) => value.clone(),\n        _ => {\n          error!(\"Unexpected value type for task field: {:?}\", value);\n          return Err(IndexerError::Internal(anyhow!(\n            \"Unexpected value type for task field: {:?}\",\n            value\n          )));\n        },\n      },\n      None => {\n        error!(\"Task field not found in Redis stream entry\");\n        return Err(IndexerError::Internal(anyhow!(\n          \"Task field not found in Redis stream entry\"\n        )));\n      },\n    };\n\n    from_str::<UnindexedCollabTask>(&task_str).map_err(|err| IndexerError::Internal(err.into()))\n  }\n}\n\n/// Adds a list of tasks to the Redis stream.\n///\n/// This function pushes a batch of `EmbedderTask` items into the Redis stream for processing.\n/// The tasks are serialized into JSON format before being added to the stream.\n///\npub async fn add_background_embed_task(\n  redis_client: ConnectionManager,\n  tasks: Vec<UnindexedCollabTask>,\n) -> Result<(), AppError> {\n  let items = tasks\n    .into_iter()\n    .flat_map(|task| {\n      let task = serde_json::to_string(&task).ok()?;\n      Some((\"task\", task))\n    })\n    .collect::<Vec<_>>();\n\n  let _: () = redis_client\n    .clone()\n    .xadd(INDEX_TASK_STREAM_NAME, \"*\", &items)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow!(\n        \"Failed to push embedder task to Redis stream: {}\",\n        err\n      ))\n    })?;\n  Ok(())\n}\n\n/// Reads tasks from the Redis stream for processing by a consumer group.\npub async fn read_background_embed_tasks(\n  redis_client: &mut ConnectionManager,\n  options: &StreamReadOptions,\n) -> Result<StreamReadReply, IndexerError> {\n  let tasks: StreamReadReply = match redis_client\n    .xread_options(&[INDEX_TASK_STREAM_NAME], &[\">\"], options)\n    .await\n  {\n    Ok(tasks) => tasks,\n    Err(err) => {\n      error!(\"Failed to read tasks from Redis stream: {:?}\", err);\n      if let Some(code) = err.code() {\n        if code == \"NOGROUP\" {\n          return Err(IndexerError::StreamGroupNotExist(\n            INDEXER_WORKER_GROUP_NAME.to_string(),\n          ));\n        }\n      }\n      return Err(IndexerError::Internal(err.into()));\n    },\n  };\n  Ok(tasks)\n}\n\n/// Acknowledges a task in a Redis stream and optionally removes it from the stream.\n///\n/// It is used to acknowledge the processing of a task in a Redis stream\n/// within a specific consumer group. Once a task is acknowledged, it is removed from\n/// the **Pending Entries List (PEL)** for the consumer group. If the `delete_task`\n/// flag is set to `true`, the task will also be removed from the Redis stream entirely.\n///\n/// # Parameters:\n/// - `redis_client`: A mutable reference to the Redis `ConnectionManager`, used to\n///   interact with the Redis server.\n/// - `stream_entity_id`: The unique identifier (ID) of the task in the stream.\n/// - `delete_task`: A boolean flag that indicates whether the task should be removed\n///   from the stream after it is acknowledged. If `true`, the task is deleted from the stream.\n///   If `false`, the task remains in the stream after acknowledgment.\npub async fn ack_task(\n  redis_client: &mut ConnectionManager,\n  stream_entity_ids: Vec<String>,\n  delete_task: bool,\n) -> Result<(), IndexerError> {\n  let _: () = redis_client\n    .xack(\n      INDEX_TASK_STREAM_NAME,\n      INDEXER_WORKER_GROUP_NAME,\n      &stream_entity_ids,\n    )\n    .await\n    .map_err(|err| {\n      error!(\"Failed to ack task: {:?}\", err);\n      IndexerError::Internal(err.into())\n    })?;\n\n  if delete_task {\n    let _: () = redis_client\n      .xdel(INDEX_TASK_STREAM_NAME, &stream_entity_ids)\n      .await\n      .map_err(|err| {\n        error!(\"Failed to delete task: {:?}\", err);\n        IndexerError::Internal(err.into())\n      })?;\n  }\n\n  Ok(())\n}\n\npub fn default_indexer_group_option(limit: usize) -> StreamReadOptions {\n  StreamReadOptions::default()\n    .group(INDEXER_WORKER_GROUP_NAME, INDEXER_CONSUMER_NAME)\n    .count(limit)\n}\n\n/// Ensure the consumer group exists, if not, create it.\npub async fn ensure_indexer_consumer_group(\n  redis_client: &mut ConnectionManager,\n) -> Result<(), IndexerError> {\n  let result: RedisResult<()> = redis_client\n    .xgroup_create_mkstream(INDEX_TASK_STREAM_NAME, INDEXER_WORKER_GROUP_NAME, \"0\")\n    .await;\n\n  if let Err(redis_error) = result {\n    if let Some(code) = redis_error.code() {\n      if code == \"BUSYGROUP\" {\n        return Ok(());\n      }\n\n      if code == \"NOGROUP\" {\n        return Err(IndexerError::StreamGroupNotExist(\n          INDEXER_WORKER_GROUP_NAME.to_string(),\n        ));\n      }\n    }\n    error!(\"Error when creating consumer group: {:?}\", redis_error);\n    return Err(IndexerError::Internal(redis_error.into()));\n  }\n\n  Ok(())\n}\n"
  },
  {
    "path": "libs/indexer/src/scheduler.rs",
    "content": "use crate::collab_indexer::IndexerProvider;\nuse crate::entity::EmbeddingRecord;\nuse crate::metrics::EmbeddingMetrics;\nuse crate::queue::add_background_embed_task;\nuse crate::vector::embedder::AFEmbedder;\nuse crate::vector::open_ai;\nuse app_error::AppError;\nuse async_openai::config::{AzureConfig, OpenAIConfig};\nuse async_openai::types::{CreateEmbeddingRequest, CreateEmbeddingResponse};\nuse collab::preclude::Collab;\nuse collab_document::document::DocumentBody;\nuse collab_entity::CollabType;\nuse database::collab::CollabStore;\nuse database::index::{\n  get_collab_embedding_fragment_ids, update_collab_indexed_at, upsert_collab_embeddings,\n};\nuse database::workspace::select_workspace_settings;\nuse infra::env_util::get_env_var;\nuse redis::aio::ConnectionManager;\nuse serde::{Deserialize, Serialize};\nuse sqlx::PgPool;\nuse std::cmp::max;\nuse std::collections::HashSet;\nuse std::ops::DerefMut;\nuse std::sync::{Arc, Weak};\nuse std::time::{Duration, Instant};\nuse tokio::sync::mpsc;\nuse tokio::sync::mpsc::error::TrySendError;\nuse tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};\nuse tokio::sync::RwLock as TokioRwLock;\nuse tokio::task::JoinSet;\nuse tokio::time::timeout;\nuse tracing::{debug, error, info, instrument, trace, warn};\nuse uuid::Uuid;\n\npub struct IndexerScheduler {\n  pub(crate) indexer_provider: Arc<IndexerProvider>,\n  pub(crate) pg_pool: PgPool,\n  pub(crate) storage: Arc<dyn CollabStore>,\n  #[allow(dead_code)]\n  pub(crate) metrics: Arc<EmbeddingMetrics>,\n  write_embedding_tx: UnboundedSender<EmbeddingRecord>,\n  generate_embedding_tx: mpsc::Sender<UnindexedCollabTask>,\n  config: IndexerConfiguration,\n  redis_client: ConnectionManager,\n}\n\n#[derive(Debug)]\npub struct IndexerConfiguration {\n  pub enable: bool,\n  pub open_ai_config: Option<OpenAIConfig>,\n  pub azure_ai_config: Option<AzureConfig>,\n  /// High watermark for the number of embeddings that can be buffered before being written to the database.\n  pub embedding_buffer_size: usize,\n}\n\nimpl IndexerScheduler {\n  pub fn new(\n    indexer_provider: Arc<IndexerProvider>,\n    pg_pool: PgPool,\n    storage: Arc<dyn CollabStore>,\n    metrics: Arc<EmbeddingMetrics>,\n    config: IndexerConfiguration,\n    redis_client: ConnectionManager,\n  ) -> Arc<Self> {\n    // Since threads often block while waiting for I/O, you can use more threads than CPU cores to improve concurrency.\n    // A good rule of thumb is 2x to 10x the number of CPU cores\n    let buffer_size = max(\n      get_env_var(\"APPFLOWY_INDEXER_SCHEDULER_NUM_THREAD\", \"50\")\n        .parse::<usize>()\n        .unwrap_or(50),\n      5,\n    );\n\n    info!(\"Indexer scheduler config: {:?}\", config);\n    let (write_embedding_tx, write_embedding_rx) = unbounded_channel::<EmbeddingRecord>();\n    let (generate_embedding_tx, generate_embedding_rx) =\n      mpsc::channel::<UnindexedCollabTask>(config.embedding_buffer_size);\n\n    let this = Arc::new(Self {\n      indexer_provider,\n      pg_pool,\n      storage,\n      metrics,\n      write_embedding_tx,\n      generate_embedding_tx,\n      config,\n      redis_client,\n    });\n\n    info!(\"Indexer scheduler is enabled: {}\", this.index_enabled(),);\n\n    let latest_write_embedding_err = Arc::new(TokioRwLock::new(None));\n    if this.index_enabled() {\n      tokio::spawn(generate_embeddings_loop(\n        generate_embedding_rx,\n        Arc::downgrade(&this),\n        buffer_size,\n        latest_write_embedding_err.clone(),\n      ));\n\n      tokio::spawn(spawn_pg_write_embeddings(\n        write_embedding_rx,\n        this.pg_pool.clone(),\n        this.metrics.clone(),\n        latest_write_embedding_err,\n      ));\n    }\n\n    this\n  }\n\n  fn index_enabled(&self) -> bool {\n    self.config.enable\n      && (self.config.open_ai_config.is_some() || self.config.azure_ai_config.is_some())\n  }\n\n  pub fn is_indexing_enabled(&self, collab_type: CollabType) -> bool {\n    self.indexer_provider.is_indexing_enabled(collab_type)\n  }\n\n  pub(crate) fn create_embedder(&self) -> Result<AFEmbedder, AppError> {\n    if let Some(config) = &self.config.azure_ai_config {\n      return Ok(AFEmbedder::AzureOpenAI(open_ai::AzureOpenAIEmbedder::new(\n        config.clone(),\n      )));\n    }\n\n    if let Some(config) = &self.config.open_ai_config {\n      return Ok(AFEmbedder::OpenAI(open_ai::OpenAIEmbedder::new(\n        config.clone(),\n      )));\n    }\n\n    Err(AppError::AIServiceUnavailable(\n      \"No embedder available\".to_string(),\n    ))\n  }\n\n  pub async fn create_search_embeddings(\n    &self,\n    request: CreateEmbeddingRequest,\n  ) -> Result<CreateEmbeddingResponse, AppError> {\n    let embedder = self.create_embedder()?;\n    let embeddings = embedder.async_embed(request).await?;\n    Ok(embeddings)\n  }\n\n  pub fn embed_in_background(\n    &self,\n    pending_collabs: Vec<UnindexedCollabTask>,\n  ) -> Result<(), AppError> {\n    if !self.index_enabled() {\n      return Ok(());\n    }\n\n    let redis_client = self.redis_client.clone();\n    tokio::spawn(add_background_embed_task(redis_client, pending_collabs));\n    Ok(())\n  }\n\n  pub fn embed_immediately(&self, pending_collab: UnindexedCollabTask) -> Result<(), AppError> {\n    if !self.index_enabled() {\n      return Ok(());\n    }\n    if let Err(err) = self.generate_embedding_tx.try_send(pending_collab) {\n      match err {\n        TrySendError::Full(pending) => {\n          warn!(\"[Embedding] Embedding queue is full, embedding in background\");\n          self.embed_in_background(vec![pending])?;\n          self.metrics.record_failed_embed_count(1);\n        },\n        TrySendError::Closed(_) => {\n          error!(\"Failed to send embedding record: channel closed\");\n        },\n      }\n    }\n\n    Ok(())\n  }\n\n  pub fn index_pending_collab_one(\n    &self,\n    pending_collab: UnindexedCollabTask,\n    background: bool,\n  ) -> Result<(), AppError> {\n    if !self.index_enabled() {\n      return Ok(());\n    }\n\n    let indexer = self\n      .indexer_provider\n      .indexer_for(pending_collab.collab_type);\n    if indexer.is_none() {\n      return Ok(());\n    }\n\n    if background {\n      let _ = self.embed_in_background(vec![pending_collab]);\n    } else {\n      let _ = self.embed_immediately(pending_collab);\n    }\n    Ok(())\n  }\n\n  /// Index all pending collabs in the background\n  pub fn index_pending_collabs(\n    &self,\n    mut pending_collabs: Vec<UnindexedCollabTask>,\n  ) -> Result<(), AppError> {\n    if !self.index_enabled() {\n      return Ok(());\n    }\n\n    pending_collabs.retain(|collab| self.is_indexing_enabled(collab.collab_type));\n    if pending_collabs.is_empty() {\n      return Ok(());\n    }\n\n    info!(\"indexing {} collabs in background\", pending_collabs.len());\n    let _ = self.embed_in_background(pending_collabs);\n\n    Ok(())\n  }\n\n  pub async fn index_collab_immediately(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab: &Collab,\n    collab_type: CollabType,\n  ) -> Result<(), AppError> {\n    if !self.index_enabled() {\n      return Ok(());\n    }\n\n    if !self.is_indexing_enabled(collab_type) {\n      return Ok(());\n    }\n\n    match collab_type {\n      CollabType::Document => {\n        let txn = collab.transact();\n        let text = DocumentBody::from_collab(collab)\n          .map(|body| body.to_plain_text(txn))\n          .unwrap_or_default();\n\n        if !text.is_empty() {\n          let pending = UnindexedCollabTask::new(\n            workspace_id,\n            object_id,\n            collab_type,\n            UnindexedData::Paragraphs(text),\n          );\n          self.embed_immediately(pending)?;\n        }\n      },\n      _ => {\n        // TODO(nathan): support other collab types\n      },\n    }\n\n    Ok(())\n  }\n\n  pub async fn can_index_workspace(&self, workspace_id: &Uuid) -> Result<bool, AppError> {\n    if !self.index_enabled() {\n      return Ok(false);\n    }\n\n    let settings = select_workspace_settings(&self.pg_pool, workspace_id).await?;\n    match settings {\n      None => Ok(true),\n      Some(settings) => Ok(!settings.disable_search_indexing),\n    }\n  }\n}\n\nasync fn generate_embeddings_loop(\n  mut rx: mpsc::Receiver<UnindexedCollabTask>,\n  scheduler: Weak<IndexerScheduler>,\n  buffer_size: usize,\n  latest_write_embedding_err: Arc<TokioRwLock<Option<AppError>>>,\n) {\n  let mut buf = Vec::with_capacity(buffer_size);\n  loop {\n    let latest_error = latest_write_embedding_err.write().await.take();\n    if let Some(err) = latest_error {\n      if matches!(err, AppError::ActionTimeout(_)) {\n        info!(\n          \"[Embedding] last write embedding task failed with timeout, waiting for 30s before retrying...\"\n        );\n        tokio::time::sleep(Duration::from_secs(30)).await;\n      }\n    }\n\n    let n = rx.recv_many(&mut buf, buffer_size).await;\n    let scheduler = match scheduler.upgrade() {\n      Some(scheduler) => scheduler,\n      None => {\n        error!(\"[Embedding] Failed to upgrade scheduler\");\n        break;\n      },\n    };\n\n    if n == 0 {\n      info!(\"[Embedding] Stop generating embeddings\");\n      break;\n    }\n\n    let start = Instant::now();\n    let records = buf.drain(..n).collect::<Vec<_>>();\n    trace!(\n      \"[Embedding] received {} embeddings to generate\",\n      records.len()\n    );\n    let metrics = scheduler.metrics.clone();\n    let indexer_provider = scheduler.indexer_provider.clone();\n    let write_embedding_tx = scheduler.write_embedding_tx.clone();\n    let embedder = scheduler.create_embedder();\n    match embedder {\n      Ok(embedder) => {\n        let params: Vec<_> = records.iter().map(|r| r.object_id).collect();\n        let existing_embeddings = get_collab_embedding_fragment_ids(&scheduler.pg_pool, params)\n          .await\n          .unwrap_or_else(|err| {\n            error!(\"[Embedding] failed to get existing embeddings: {}\", err);\n            Default::default()\n          });\n        let mut join_set = JoinSet::new();\n        for record in records {\n          if let Some(indexer) = indexer_provider.indexer_for(record.collab_type) {\n            metrics.record_embed_count(1);\n            let paragraphs = match record.data {\n              UnindexedData::Paragraphs(paragraphs) => paragraphs,\n              UnindexedData::Text(text) => text.split('\\n').map(|s| s.to_string()).collect(),\n            };\n            let embedder = embedder.clone();\n            match indexer.create_embedded_chunks_from_text(\n              record.object_id,\n              paragraphs,\n              embedder.model(),\n            ) {\n              Ok(mut chunks) => {\n                if let Some(fragment_ids) = existing_embeddings.get(&record.object_id) {\n                  for chunk in chunks.iter_mut() {\n                    if fragment_ids.contains(&chunk.fragment_id) {\n                      chunk.mark_as_duplicate();\n                    }\n                  }\n                }\n\n                join_set.spawn(async move {\n                  let result = indexer.embed(&embedder, chunks).await;\n                  match result {\n                    Ok(Some(embeddings)) => {\n                      let record = EmbeddingRecord {\n                        workspace_id: record.workspace_id,\n                        object_id: record.object_id,\n                        collab_type: record.collab_type,\n                        tokens_used: embeddings.tokens_consumed,\n                        chunks: embeddings.chunks,\n                      };\n                      Ok(Some(record))\n                    },\n                    Ok(None) => Ok(None),\n                    Err(err) => Err(err),\n                  }\n                });\n              },\n              Err(err) => {\n                metrics.record_failed_embed_count(1);\n                warn!(\n                  \"Failed to create embedded chunks for collab: {}, error:{}\",\n                  record.object_id, err\n                );\n                continue;\n              },\n            }\n          }\n        }\n        while let Some(Ok(res)) = join_set.join_next().await {\n          scheduler\n            .metrics\n            .record_gen_embedding_time(n as u32, start.elapsed().as_millis());\n          match res {\n            Ok(Some(record)) => {\n              if let Err(err) = write_embedding_tx.send(record) {\n                error!(\"Failed to send embedding record: {}\", err);\n              }\n            },\n            Ok(None) => trace!(\n              \"[Embedding] Did found existing embeddings. Skip generate embedding for collab\"\n            ),\n            Err(err) => {\n              metrics.record_failed_embed_count(1);\n              warn!(\n                \"Failed to create embeddings content for collab, error:{}\",\n                err\n              );\n            },\n          }\n        }\n      },\n      Err(err) => error!(\"[Embedding] Failed to create embedder: {}\", err),\n    }\n  }\n}\n\nconst EMBEDDING_RECORD_BUFFER_SIZE: usize = 10;\npub async fn spawn_pg_write_embeddings(\n  mut rx: UnboundedReceiver<EmbeddingRecord>,\n  pg_pool: PgPool,\n  metrics: Arc<EmbeddingMetrics>,\n  latest_write_embedding_error: Arc<TokioRwLock<Option<AppError>>>,\n) {\n  let mut buf = Vec::with_capacity(EMBEDDING_RECORD_BUFFER_SIZE);\n  loop {\n    let n = rx.recv_many(&mut buf, EMBEDDING_RECORD_BUFFER_SIZE).await;\n    if n == 0 {\n      info!(\"Stop writing embeddings\");\n      break;\n    }\n\n    let start = Instant::now();\n    let records = buf.drain(..n).collect::<Vec<_>>();\n    for record in records.iter() {\n      debug!(\n        \"[Embedding] pg write collab:{} embeddings, tokens used: {}\",\n        record.object_id, record.tokens_used\n      );\n    }\n\n    let result = timeout(\n      Duration::from_secs(20),\n      batch_insert_records(&pg_pool, records),\n    )\n    .await\n    .unwrap_or_else(|_| {\n      Err(AppError::ActionTimeout(\n        \"timeout when writing embeddings\".to_string(),\n      ))\n    });\n\n    match result {\n      Ok(_) => {\n        metrics.record_write_embedding_time(start.elapsed().as_millis());\n      },\n      Err(err) => {\n        error!(\"Failed to write collab embedding to disk:{}\", err);\n        latest_write_embedding_error.write().await.replace(err);\n      },\n    }\n  }\n}\n\n#[instrument(level = \"trace\", skip_all)]\npub(crate) async fn batch_insert_records(\n  pg_pool: &PgPool,\n  records: Vec<EmbeddingRecord>,\n) -> Result<(), AppError> {\n  let mut seen = HashSet::new();\n  let records = records\n    .into_iter()\n    .filter(|record| seen.insert(record.object_id))\n    .collect::<Vec<_>>();\n\n  let mut txn = pg_pool.begin().await?;\n  for record in records {\n    update_collab_indexed_at(\n      txn.deref_mut(),\n      &record.object_id,\n      &record.collab_type,\n      chrono::Utc::now(),\n    )\n    .await?;\n\n    upsert_collab_embeddings(\n      &mut txn,\n      &record.workspace_id,\n      &record.object_id,\n      record.tokens_used,\n      record.chunks,\n    )\n    .await?;\n  }\n  txn.commit().await.map_err(|e| {\n    error!(\"[Embedding] Failed to commit transaction: {:?}\", e);\n    e\n  })?;\n\n  Ok(())\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UnindexedCollabTask {\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n  pub collab_type: CollabType,\n  pub data: UnindexedData,\n  pub created_at: i64,\n}\n\nimpl UnindexedCollabTask {\n  pub fn new(\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    data: UnindexedData,\n  ) -> Self {\n    Self {\n      workspace_id,\n      object_id,\n      collab_type,\n      data,\n      created_at: chrono::Utc::now().timestamp(),\n    }\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub enum UnindexedData {\n  Text(String),\n  Paragraphs(Vec<String>),\n}\n\nimpl UnindexedData {\n  pub fn is_empty(&self) -> bool {\n    match self {\n      UnindexedData::Text(text) => text.is_empty(),\n      UnindexedData::Paragraphs(text) => text.is_empty(),\n    }\n  }\n}\n"
  },
  {
    "path": "libs/indexer/src/unindexed_workspace.rs",
    "content": "use crate::collab_indexer::IndexerProvider;\nuse crate::entity::{EmbeddingRecord, UnindexedCollab};\nuse crate::scheduler::{batch_insert_records, IndexerScheduler};\nuse crate::vector::embedder::AFEmbedder;\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse database::collab::{CollabStore, GetCollabOrigin};\nuse database::index::{get_collab_embedding_fragment_ids, stream_collabs_without_embeddings};\nuse futures_util::stream::BoxStream;\nuse futures_util::StreamExt;\nuse rayon::iter::ParallelIterator;\nuse rayon::prelude::IntoParallelIterator;\nuse sqlx::pool::PoolConnection;\nuse sqlx::Postgres;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::task::JoinSet;\nuse tracing::{error, info, instrument, trace};\nuse uuid::Uuid;\n\n/// # index given workspace\n///\n/// Continuously processes and creates embeddings for unindexed collabs in a specified workspace.\n///\n/// This function runs in an infinite loop until a connection to the database cannot be established\n/// for an extended period. It streams unindexed collabs from the database in batches, processes them\n/// to create embeddings, and writes those embeddings back to the database.\n///\n#[allow(dead_code)]\npub(crate) async fn index_workspace(scheduler: Arc<IndexerScheduler>, workspace_id: Uuid) {\n  let mut retry_delay = Duration::from_secs(2);\n  loop {\n    let conn = scheduler.pg_pool.try_acquire();\n    if conn.is_none() {\n      tokio::time::sleep(retry_delay).await;\n      // 4s, 8s, 16s, 32s, 60s\n      retry_delay = retry_delay.saturating_mul(2);\n      if retry_delay > Duration::from_secs(60) {\n        error!(\"[Embedding] failed to acquire db connection for 1 minute, stop indexing\");\n        break;\n      }\n      continue;\n    }\n\n    retry_delay = Duration::from_secs(2);\n    let mut conn = conn.unwrap();\n    let mut stream =\n      stream_unindexed_collabs(&mut conn, workspace_id, scheduler.storage.clone(), 50).await;\n\n    let batch_size = 5;\n    let mut unindexed_collabs = Vec::with_capacity(batch_size);\n    while let Some(Ok(collab)) = stream.next().await {\n      if unindexed_collabs.len() < batch_size {\n        unindexed_collabs.push(collab);\n        continue;\n      }\n\n      _index_then_write_embedding_to_disk(&scheduler, std::mem::take(&mut unindexed_collabs)).await;\n    }\n\n    if !unindexed_collabs.is_empty() {\n      _index_then_write_embedding_to_disk(&scheduler, unindexed_collabs).await;\n    }\n  }\n}\n\nasync fn _index_then_write_embedding_to_disk(\n  scheduler: &Arc<IndexerScheduler>,\n  unindexed_collabs: Vec<UnindexedCollab>,\n) {\n  info!(\n    \"[Embedding] process batch {:?} embeddings\",\n    unindexed_collabs\n      .iter()\n      .map(|v| v.object_id)\n      .collect::<Vec<_>>()\n  );\n\n  if let Ok(embedder) = scheduler.create_embedder() {\n    let start = Instant::now();\n    let object_ids = unindexed_collabs\n      .iter()\n      .map(|v| v.object_id)\n      .collect::<Vec<_>>();\n    match get_collab_embedding_fragment_ids(&scheduler.pg_pool, object_ids).await {\n      Ok(existing_embeddings) => {\n        let embeddings = _create_embeddings(\n          embedder,\n          &scheduler.indexer_provider,\n          unindexed_collabs,\n          existing_embeddings,\n        )\n        .await;\n        scheduler\n          .metrics\n          .record_gen_embedding_time(embeddings.len() as u32, start.elapsed().as_millis());\n\n        let write_start = Instant::now();\n        let n = embeddings.len();\n        match batch_insert_records(&scheduler.pg_pool, embeddings).await {\n          Ok(_) => trace!(\n            \"[Embedding] upsert {} embeddings success, cost:{}ms\",\n            n,\n            write_start.elapsed().as_millis()\n          ),\n          Err(err) => error!(\"{}\", err),\n        }\n\n        scheduler\n          .metrics\n          .record_write_embedding_time(write_start.elapsed().as_millis());\n        tokio::time::sleep(Duration::from_secs(5)).await;\n      },\n      Err(err) => error!(\"[Embedding] failed to get fragment ids: {}\", err),\n    }\n  } else {\n    trace!(\"[Embedding] no embeddings to process in this batch\");\n  }\n}\n\n#[instrument(level = \"trace\", skip_all)]\nasync fn stream_unindexed_collabs(\n  conn: &mut PoolConnection<Postgres>,\n  workspace_id: Uuid,\n  storage: Arc<dyn CollabStore>,\n  limit: i64,\n) -> BoxStream<Result<UnindexedCollab, anyhow::Error>> {\n  let cloned_storage = storage.clone();\n  stream_collabs_without_embeddings(conn, workspace_id, limit)\n    .await\n    .map(move |result| {\n      let storage = cloned_storage.clone();\n      async move {\n        match result {\n          Ok(cid) => match cid.collab_type {\n            CollabType::Document => {\n              let collab = storage\n                .get_full_encode_collab(\n                  GetCollabOrigin::Server,\n                  &cid.workspace_id,\n                  &cid.object_id,\n                  cid.collab_type,\n                )\n                .await?\n                .encoded_collab;\n\n              Ok(Some(UnindexedCollab {\n                workspace_id: cid.workspace_id,\n                object_id: cid.object_id,\n                collab_type: cid.collab_type,\n                collab,\n              }))\n            },\n            // TODO(nathan): support other collab types\n            _ => Ok::<_, anyhow::Error>(None),\n          },\n          Err(e) => Err(e.into()),\n        }\n      }\n    })\n    .filter_map(|future| async {\n      match future.await {\n        Ok(Some(unindexed_collab)) => Some(Ok(unindexed_collab)),\n        Ok(None) => None,\n        Err(e) => Some(Err(e)),\n      }\n    })\n    .boxed()\n}\nasync fn _create_embeddings(\n  embedder: AFEmbedder,\n  indexer_provider: &Arc<IndexerProvider>,\n  unindexed_records: Vec<UnindexedCollab>,\n  existing_embeddings: HashMap<Uuid, Vec<String>>,\n) -> Vec<EmbeddingRecord> {\n  // 1. use parallel iteration since computing text chunks is CPU-intensive task\n  let records = compute_embedding_records(\n    indexer_provider,\n    embedder.model(),\n    unindexed_records,\n    existing_embeddings,\n  );\n\n  // 2. use tokio JoinSet to parallelize OpenAI calls (IO-bound)\n  let mut join_set = JoinSet::new();\n  for record in records {\n    let indexer_provider = indexer_provider.clone();\n    let embedder = embedder.clone();\n    if let Some(indexer) = indexer_provider.indexer_for(record.collab_type) {\n      join_set.spawn(async move {\n        match indexer.embed(&embedder, record.chunks).await {\n          Ok(embeddings) => embeddings.map(|embeddings| EmbeddingRecord {\n            workspace_id: record.workspace_id,\n            object_id: record.object_id,\n            collab_type: record.collab_type,\n            tokens_used: embeddings.tokens_consumed,\n            chunks: embeddings.chunks,\n          }),\n          Err(err) => {\n            error!(\"Failed to embed collab: {}\", err);\n            None\n          },\n        }\n      });\n    }\n  }\n\n  let mut results = Vec::with_capacity(join_set.len());\n  while let Some(Ok(Some(record))) = join_set.join_next().await {\n    trace!(\n      \"[Embedding] generate collab:{} embeddings, tokens used: {}\",\n      record.object_id,\n      record.tokens_used\n    );\n    results.push(record);\n  }\n  results\n}\n\nfn compute_embedding_records(\n  indexer_provider: &IndexerProvider,\n  model: EmbeddingModel,\n  unindexed_records: Vec<UnindexedCollab>,\n  existing_embeddings: HashMap<Uuid, Vec<String>>,\n) -> Vec<EmbeddingRecord> {\n  unindexed_records\n    .into_par_iter()\n    .flat_map(|unindexed| {\n      let indexer = indexer_provider.indexer_for(unindexed.collab_type)?;\n      let options = CollabOptions::new(unindexed.object_id.to_string(), default_client_id())\n        .with_data_source(DataSource::DocStateV1(unindexed.collab.doc_state.into()));\n      let collab = Collab::new_with_options(CollabOrigin::Empty, options).ok()?;\n\n      let mut chunks = indexer\n        .create_embedded_chunks_from_collab(&collab, model)\n        .ok()?;\n      if chunks.is_empty() {\n        trace!(\"[Embedding] {} has no embeddings\", unindexed.object_id,);\n        return Some(EmbeddingRecord::empty(\n          unindexed.workspace_id,\n          unindexed.object_id,\n          unindexed.collab_type,\n        ));\n      }\n\n      // compare chunks against existing fragment ids (which are content addressed) and mark these\n      // which haven't changed as already embedded\n      if let Some(existing_embeddings) = existing_embeddings.get(&unindexed.object_id) {\n        for chunk in chunks.iter_mut() {\n          if existing_embeddings.contains(&chunk.fragment_id) {\n            chunk.mark_as_duplicate();\n          }\n        }\n      }\n      Some(EmbeddingRecord {\n        workspace_id: unindexed.workspace_id,\n        object_id: unindexed.object_id,\n        collab_type: unindexed.collab_type,\n        tokens_used: 0,\n        chunks,\n      })\n    })\n    .collect()\n}\n"
  },
  {
    "path": "libs/indexer/src/vector/embedder.rs",
    "content": "use crate::vector::open_ai;\nuse crate::vector::open_ai::async_embed;\nuse app_error::AppError;\nuse appflowy_ai_client::dto::EmbeddingModel;\npub use async_openai::config::{AzureConfig, OpenAIConfig};\npub use async_openai::types::{\n  CreateEmbeddingRequest, CreateEmbeddingRequestArgs, CreateEmbeddingResponse, EmbeddingInput,\n  EncodingFormat,\n};\nuse infra::env_util::get_env_var_opt;\nuse tracing::{info, warn};\n\n#[derive(Debug, Clone)]\npub enum AFEmbedder {\n  OpenAI(open_ai::OpenAIEmbedder),\n  AzureOpenAI(open_ai::AzureOpenAIEmbedder),\n}\n\nimpl AFEmbedder {\n  pub async fn async_embed(\n    &self,\n    params: CreateEmbeddingRequest,\n  ) -> Result<CreateEmbeddingResponse, AppError> {\n    match self {\n      Self::OpenAI(embedder) => async_embed(&embedder.client, params).await,\n      Self::AzureOpenAI(embedder) => async_embed(&embedder.client, params).await,\n    }\n  }\n\n  pub fn model(&self) -> EmbeddingModel {\n    EmbeddingModel::default_model()\n  }\n}\n\npub fn get_open_ai_config() -> (Option<OpenAIConfig>, Option<AzureConfig>) {\n  let open_ai_config = open_ai_config();\n  let azure_ai_config = azure_open_ai_config();\n\n  if open_ai_config.is_some() {\n    info!(\"Using official OpenAI API\");\n    if azure_ai_config.is_some() {\n      warn!(\"Both OpenAI and Azure OpenAI API keys are set. Using OpenAI API.\");\n    }\n    return (open_ai_config, None);\n  }\n\n  info!(\"Using Azure OpenAI API\");\n  (None, azure_ai_config)\n}\n\nfn open_ai_config() -> Option<OpenAIConfig> {\n  get_env_var_opt(\"AI_OPENAI_API_KEY\").map(|v| OpenAIConfig::default().with_api_key(v))\n}\n\nfn azure_open_ai_config() -> Option<AzureConfig> {\n  let azure_open_ai_api_key = get_env_var_opt(\"AI_AZURE_OPENAI_API_KEY\")?;\n  let azure_open_ai_api_base = get_env_var_opt(\"AI_AZURE_OPENAI_API_BASE\")?;\n  let azure_open_ai_api_version = get_env_var_opt(\"AI_AZURE_OPENAI_API_VERSION\")?;\n\n  Some(\n    AzureConfig::new()\n      .with_api_key(azure_open_ai_api_key)\n      .with_api_base(azure_open_ai_api_base)\n      .with_api_version(azure_open_ai_api_version),\n  )\n}\n"
  },
  {
    "path": "libs/indexer/src/vector/mod.rs",
    "content": "pub mod embedder;\npub mod open_ai;\n"
  },
  {
    "path": "libs/indexer/src/vector/open_ai.rs",
    "content": "use app_error::AppError;\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse async_openai::config::{AzureConfig, Config, OpenAIConfig};\nuse async_openai::types::{CreateEmbeddingRequest, CreateEmbeddingResponse};\nuse async_openai::Client;\nuse text_splitter::{ChunkConfig, TextSplitter};\nuse tiktoken_rs::CoreBPE;\nuse tracing::{trace, warn};\n\npub const OPENAI_EMBEDDINGS_URL: &str = \"https://api.openai.com/v1/embeddings\";\n\npub const REQUEST_PARALLELISM: usize = 40;\n\n#[derive(Debug, Clone)]\npub struct OpenAIEmbedder {\n  pub(crate) client: Client<OpenAIConfig>,\n}\n\nimpl OpenAIEmbedder {\n  pub fn new(config: OpenAIConfig) -> Self {\n    let client = Client::with_config(config);\n\n    Self { client }\n  }\n}\n\n#[derive(Debug, Clone)]\npub struct AzureOpenAIEmbedder {\n  pub(crate) client: Client<AzureConfig>,\n}\n\nimpl AzureOpenAIEmbedder {\n  pub fn new(mut config: AzureConfig) -> Self {\n    // Make sure your Azure AI service support the model\n    config = config.with_deployment_id(EmbeddingModel::default_model().to_string());\n    let client = Client::with_config(config);\n    Self { client }\n  }\n}\n\npub async fn async_embed<C: Config>(\n  client: &Client<C>,\n  request: CreateEmbeddingRequest,\n) -> Result<CreateEmbeddingResponse, AppError> {\n  trace!(\n    \"async embed with request: model:{:?}, dimension:{:?}, api_base:{}\",\n    request.model,\n    request.dimensions,\n    client.config().api_base()\n  );\n  let response = client\n    .embeddings()\n    .create(request)\n    .await\n    .map_err(|err| AppError::Unhandled(err.to_string()))?;\n  Ok(response)\n}\n\n/// ## Execution Time Comparison Results\n///\n/// The following results were observed when running `execution_time_comparison_tests`:\n///\n/// | Content Size (chars) | Direct Time (ms) | spawn_blocking Time (ms) |\n/// |-----------------------|------------------|--------------------------|\n/// | 500                  | 1                | 1                        |\n/// | 1000                 | 2                | 2                        |\n/// | 2000                 | 5                | 5                        |\n/// | 5000                 | 11               | 11                       |\n/// | 20000                | 49               | 48                       |\n///\n/// ## Guidelines for Using `spawn_blocking`\n///\n/// - **Short Tasks (< 1 ms)**:\n///   Use direct execution on the async runtime. The minimal execution time has negligible impact.\n///\n/// - **Moderate Tasks (1–10 ms)**:\n///   - For infrequent or low-concurrency tasks, direct execution is acceptable.\n///   - For frequent or high-concurrency tasks, consider using `spawn_blocking` to avoid delays.\n///\n/// - **Long Tasks (> 10 ms)**:\n///   Always offload to a blocking thread with `spawn_blocking` to maintain runtime efficiency and responsiveness.\n///\n/// Related blog:\n/// https://tokio.rs/blog/2020-04-preemption\n/// https://ryhl.io/blog/async-what-is-blocking/\n#[inline]\n#[allow(dead_code)]\npub fn split_text_by_max_tokens(\n  content: String,\n  max_tokens: usize,\n  tokenizer: &CoreBPE,\n) -> Result<Vec<String>, AppError> {\n  if content.is_empty() {\n    return Ok(vec![]);\n  }\n\n  let token_ids = tokenizer.encode_ordinary(&content);\n  let total_tokens = token_ids.len();\n  if total_tokens <= max_tokens {\n    return Ok(vec![content]);\n  }\n\n  let mut chunks = Vec::new();\n  let mut start_idx = 0;\n  while start_idx < total_tokens {\n    let mut end_idx = (start_idx + max_tokens).min(total_tokens);\n    let mut decoded = false;\n    // Try to decode the chunk, adjust end_idx if decoding fails\n    while !decoded {\n      let token_chunk = &token_ids[start_idx..end_idx];\n      // Attempt to decode the current chunk\n      match tokenizer.decode(token_chunk.to_vec()) {\n        Ok(chunk_text) => {\n          chunks.push(chunk_text);\n          start_idx = end_idx;\n          decoded = true;\n        },\n        Err(_) => {\n          // If we can extend the chunk, do so\n          if end_idx < total_tokens {\n            end_idx += 1;\n          } else if start_idx + 1 < total_tokens {\n            // Skip the problematic token at start_idx\n            start_idx += 1;\n            end_idx = (start_idx + max_tokens).min(total_tokens);\n          } else {\n            // Cannot decode any further, break to avoid infinite loop\n            start_idx = total_tokens;\n            break;\n          }\n        },\n      }\n    }\n  }\n\n  Ok(chunks)\n}\n\n/// Groups a list of paragraphs into chunks that fit within a specified maximum content length.\n///\n/// takes a vector of paragraph strings and combines them into larger chunks,\n/// ensuring that each chunk's total byte length does not exceed the given `context_size`.\n/// Paragraphs are concatenated directly without additional separators. If a single paragraph\n/// exceeds the `context_size`, it is included as its own chunk without truncation.\n///\n/// # Arguments\n/// * `paragraphs` - A vector of strings, where each string represents a paragraph.\n/// * `context_size` - The maximum byte length allowed for each chunk.\n///\n/// # Returns\n/// A vector of strings, where each string is a chunk of concatenated paragraphs that fits\n/// within the `context_size`. If the input is empty, returns an empty vector.\npub fn group_paragraphs_by_max_content_len(\n  paragraphs: Vec<String>,\n  mut context_size: usize,\n  overlap: usize,\n) -> Vec<String> {\n  if paragraphs.is_empty() {\n    return vec![];\n  }\n\n  let mut result = Vec::new();\n  let mut current = String::with_capacity(context_size.min(4096));\n\n  if overlap > context_size {\n    warn!(\"context_size is smaller than overlap, which may lead to unexpected behavior.\");\n    context_size = 2 * overlap;\n  }\n\n  let chunk_config = ChunkConfig::new(context_size)\n    .with_overlap(overlap)\n    .unwrap();\n  let splitter = TextSplitter::new(chunk_config);\n\n  for paragraph in paragraphs {\n    if current.len() + paragraph.len() > context_size {\n      if !current.is_empty() {\n        result.push(std::mem::take(&mut current));\n      }\n\n      if paragraph.len() > context_size {\n        let paragraph_chunks = splitter.chunks(&paragraph);\n        result.extend(paragraph_chunks.map(String::from));\n      } else {\n        current.push_str(&paragraph);\n      }\n    } else {\n      // Add paragraph to current chunk\n      current.push_str(&paragraph);\n    }\n  }\n\n  if !current.is_empty() {\n    result.push(current);\n  }\n\n  result\n}\n\n#[cfg(test)]\nmod tests {\n\n  use crate::vector::open_ai::{group_paragraphs_by_max_content_len, split_text_by_max_tokens};\n  use tiktoken_rs::cl100k_base;\n\n  #[test]\n  fn test_split_at_non_utf8() {\n    let max_tokens = 10; // Small number for testing\n\n    // Content with multibyte characters (emojis)\n    let content = \"Hello 😃 World 🌍! This is a test 🚀.\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n    for content in params {\n      assert!(content.is_char_boundary(0));\n      assert!(content.is_char_boundary(content.len()));\n    }\n\n    let params = group_paragraphs_by_max_content_len(vec![content], max_tokens, 500);\n    for content in params {\n      assert!(content.is_char_boundary(0));\n      assert!(content.is_char_boundary(content.len()));\n    }\n  }\n  #[test]\n  fn test_exact_boundary_split() {\n    let max_tokens = 5; // Set to 5 tokens for testing\n    let content = \"The quick brown fox jumps over the lazy dog\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    let total_tokens = tokenizer.encode_ordinary(&content).len();\n    let expected_fragments = total_tokens.div_ceil(max_tokens);\n    assert_eq!(params.len(), expected_fragments);\n  }\n\n  #[test]\n  fn test_content_shorter_than_max_len() {\n    let max_tokens = 100;\n    let content = \"Short content\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    assert_eq!(params.len(), 1);\n    assert_eq!(params[0], content);\n  }\n\n  #[test]\n  fn test_empty_content() {\n    let max_tokens = 10;\n    let content = \"\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n    assert_eq!(params.len(), 0);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 500);\n    assert_eq!(params.len(), 0);\n  }\n\n  #[test]\n  fn test_content_with_only_multibyte_characters() {\n    let max_tokens = 1; // Set to 1 token for testing\n    let content = \"😀😃😄😁😆\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    let emojis: Vec<String> = content.chars().map(|c| c.to_string()).collect();\n    for (param, emoji) in params.iter().zip(emojis.iter()) {\n      assert_eq!(param, emoji);\n    }\n  }\n\n  #[test]\n  fn test_split_with_combining_characters() {\n    let max_tokens = 1; // Set to 1 token for testing\n    let content = \"a\\u{0301}e\\u{0301}i\\u{0301}o\\u{0301}u\\u{0301}\".to_string(); // \"áéíóú\"\n\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n    let total_tokens = tokenizer.encode_ordinary(&content).len();\n    assert_eq!(params.len(), total_tokens);\n    let reconstructed_content = params.join(\"\");\n    assert_eq!(reconstructed_content, content);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 500);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n  }\n\n  #[test]\n  fn test_large_content() {\n    let max_tokens = 1000;\n    let content = \"a\".repeat(5000); // 5000 characters\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    let total_tokens = tokenizer.encode_ordinary(&content).len();\n    let expected_fragments = total_tokens.div_ceil(max_tokens);\n    assert_eq!(params.len(), expected_fragments);\n  }\n\n  #[test]\n  fn test_non_ascii_characters() {\n    let max_tokens = 2;\n    let content = \"áéíóú\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    let total_tokens = tokenizer.encode_ordinary(&content).len();\n    let expected_fragments = total_tokens.div_ceil(max_tokens);\n    assert_eq!(params.len(), expected_fragments);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 500);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n  }\n\n  #[test]\n  fn test_content_with_leading_and_trailing_whitespace() {\n    let max_tokens = 3;\n    let content = \"  abcde  \".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n\n    let total_tokens = tokenizer.encode_ordinary(&content).len();\n    let expected_fragments = total_tokens.div_ceil(max_tokens);\n    assert_eq!(params.len(), expected_fragments);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 10);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n  }\n\n  #[test]\n  fn test_content_with_multiple_zero_width_joiners() {\n    let max_tokens = 1;\n    let content = \"👩‍👩‍👧‍👧👨‍👨‍👦‍👦\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 10);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n  }\n\n  #[test]\n  fn test_content_with_long_combining_sequences() {\n    let max_tokens = 1;\n    let content = \"a\\u{0300}\\u{0301}\\u{0302}\\u{0303}\\u{0304}\".to_string();\n    let tokenizer = cl100k_base().unwrap();\n    let params = split_text_by_max_tokens(content.clone(), max_tokens, &tokenizer).unwrap();\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n\n    let params = group_paragraphs_by_max_content_len(params, max_tokens, 10);\n    let reconstructed_content: String = params.concat();\n    assert_eq!(reconstructed_content, content);\n  }\n\n  #[test]\n  fn test_multiple_paragraphs_single_chunk() {\n    let paragraphs = vec![\n      \"First paragraph.\".to_string(),\n      \"Second paragraph.\".to_string(),\n      \"Third paragraph.\".to_string(),\n    ];\n    let result = group_paragraphs_by_max_content_len(paragraphs, 100, 5);\n    assert_eq!(result.len(), 1);\n    assert_eq!(\n      result[0],\n      \"First paragraph.Second paragraph.Third paragraph.\"\n    );\n  }\n\n  #[test]\n  fn test_large_paragraph_splitting() {\n    // Create a paragraph larger than context size\n    let large_paragraph = \"A\".repeat(50);\n    let paragraphs = vec![\n      \"Small paragraph.\".to_string(),\n      large_paragraph.clone(),\n      \"Another small one.\".to_string(),\n    ];\n\n    let result = group_paragraphs_by_max_content_len(paragraphs, 30, 10);\n\n    // Expected: \"Small paragraph.\" as one chunk, then multiple chunks for the large paragraph,\n    // then \"Another small one.\" as the final chunk\n    assert!(result.len() > 3); // At least 4 chunks (1 + at least 2 for large + 1)\n    assert_eq!(result[0], \"Small paragraph.\");\n    assert!(result[1].starts_with(\"A\"));\n\n    // Check that all chunks of the large paragraph together contain the original text\n    let large_chunks = &result[1..result.len() - 1];\n    let reconstructed = large_chunks.join(\"\");\n    // Due to overlaps, the reconstructed text might be longer\n    assert!(large_paragraph.len() <= reconstructed.len());\n    assert!(reconstructed.chars().all(|c| c == 'A'));\n  }\n\n  #[test]\n  fn test_overlap_larger_than_context_size() {\n    let paragraphs = vec![\n      \"First paragraph.\".to_string(),\n      \"Second very long paragraph that needs to be split.\".to_string(),\n    ];\n\n    // Overlap larger than context size\n    let result = group_paragraphs_by_max_content_len(paragraphs, 10, 20);\n\n    // Check that the function didn't panic and produced reasonable output\n    assert!(!result.is_empty());\n    assert_eq!(result[0], \"First paragraph.\");\n    assert_eq!(result[1], \"Second very long paragraph that needs to\");\n    assert_eq!(result[2], \"that needs to be split.\");\n\n    assert!(result.iter().all(|chunk| chunk.len() <= 40));\n  }\n\n  #[test]\n  fn test_exact_fit() {\n    let paragraph1 = \"AAAA\".to_string(); // 4 bytes\n    let paragraph2 = \"BBBB\".to_string(); // 4 bytes\n    let paragraph3 = \"CCCC\".to_string(); // 4 bytes\n\n    let paragraphs = vec![paragraph1, paragraph2, paragraph3];\n\n    // Context size exactly fits 2 paragraphs\n    let result = group_paragraphs_by_max_content_len(paragraphs, 8, 2);\n\n    assert_eq!(result.len(), 2);\n    assert_eq!(result[0], \"AAAABBBB\");\n    assert_eq!(result[1], \"CCCC\");\n  }\n  #[test]\n  fn test_edit_paragraphs() {\n    // Create initial paragraphs and convert them to Strings.\n    let mut paragraphs = vec![\n      \"Rust is a multiplayer survival game developed by Facepunch Studios,\",\n      \"Rust is a modern, system-level programming language designed with a focus on performance, safety, and concurrency. \",\n      \"Rust as a Natural Process (Oxidation) refers to the chemical reaction that occurs when metals, primarily iron, come into contact with oxygen and moisture (water) over time, leading to the formation of iron oxide, commonly known as rust. This process is a form of oxidation, where a substance reacts with oxygen in the air or water, resulting in the degradation of the metal.\",\n    ]\n        .into_iter()\n        .map(|s| s.to_string())\n        .collect::<Vec<_>>();\n\n    // Confirm the original lengths of the paragraphs.\n    assert_eq!(paragraphs[0].len(), 67);\n    assert_eq!(paragraphs[1].len(), 115);\n    assert_eq!(paragraphs[2].len(), 374);\n\n    // First grouping of paragraphs with a context size of 200 and overlap 100.\n    // Expecting 4 chunks based on the original paragraphs.\n    let result = group_paragraphs_by_max_content_len(paragraphs.clone(), 200, 100);\n    assert_eq!(result.len(), 4);\n\n    // Edit paragraph 0 by appending more text, simulating a content update.\n    paragraphs.get_mut(0).unwrap().push_str(\n      \" first released in early access in December 2013 and fully launched in February 2018\",\n    );\n    // After edit, confirm that paragraph 0's length is updated.\n    assert_eq!(paragraphs[0].len(), 151);\n\n    // Group paragraphs again after the edit.\n    // The change should cause a shift in grouping, expecting 5 chunks now.\n    let result_2 = group_paragraphs_by_max_content_len(paragraphs.clone(), 200, 100);\n    assert_eq!(result_2.len(), 5);\n\n    // Verify that parts of the original grouping (later chunks) remain the same:\n    // The third chunk from the first run should equal the fourth from the second run.\n    assert_eq!(result[2], result_2[3]);\n    // The fourth chunk from the first run should equal the fifth from the second run.\n    assert_eq!(result[3], result_2[4]);\n\n    // Edit paragraph 1 by appending extra text, simulating another update.\n    paragraphs\n      .get_mut(1)\n      .unwrap()\n      .push_str(\"It was created by Mozilla.\");\n\n    // Group paragraphs once again after the second edit.\n    let result_3 = group_paragraphs_by_max_content_len(paragraphs.clone(), 200, 100);\n    assert_eq!(result_3.len(), 5);\n\n    // Confirm that the grouping for the unchanged parts is still consistent:\n    // The first chunk from the previous grouping (before editing paragraph 1) stays the same.\n    assert_eq!(result_2[0], result_3[0]);\n    // Similarly, the second and third chunks (from result_2) remain unchanged.\n    assert_eq!(result_2[2], result_3[2]);\n    // The fourth chunk in both groupings should still be identical.\n    assert_eq!(result_2[3], result_3[3]);\n    // And the fifth chunk in both groupings is compared for consistency.\n    assert_eq!(result_2[4], result_3[4]);\n  }\n}\n"
  },
  {
    "path": "libs/infra/Cargo.toml",
    "content": "[package]\nname = \"infra\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nreqwest = { workspace = true, optional = true }\nanyhow.workspace = true\nserde.workspace = true\nserde_json.workspace = true\ntracing.workspace = true\nbytes = { workspace = true }\ntokio = { workspace = true }\npin-project.workspace = true\nfutures = \"0.3.30\"\nvalidator = { workspace = true, features = [\"validator_derive\", \"derive\"] }\nthiserror = { workspace = true }\nrayon = { workspace = true }\n\n[features]\nfile_util = [\"tokio/fs\"]\nrequest_util = [\"reqwest\"]"
  },
  {
    "path": "libs/infra/src/env_util.rs",
    "content": "use std::env::VarError;\n\npub fn get_env_var(key: &str, default: &str) -> String {\n  std::env::var(key).unwrap_or_else(|err| {\n    match err {\n      VarError::NotPresent => {\n        tracing::info!(\"using default environment variable {}:{}\", key, default)\n      },\n      VarError::NotUnicode(_) => {\n        tracing::error!(\n          \"{} is not a valid UTF-8 string, use default value:{}\",\n          key,\n          default\n        );\n      },\n    }\n    default.to_owned()\n  })\n}\n\n/// Optionally get an environment variable.\n/// if value is empty, return None.\npub fn get_env_var_opt(key: &str) -> Option<String> {\n  match std::env::var(key) {\n    Ok(val) => {\n      if val.is_empty() {\n        None\n      } else {\n        Some(val)\n      }\n    },\n    Err(_) => {\n      tracing::info!(\"using default environment variable {}:None\", key);\n      None\n    },\n  }\n}\n"
  },
  {
    "path": "libs/infra/src/file_util.rs",
    "content": "use anyhow::anyhow;\nuse bytes::Bytes;\nuse std::ops::Deref;\nuse std::path::PathBuf;\nuse tokio::io::AsyncReadExt;\n\npub const MIN_CHUNK_SIZE: usize = 5 * 1024 * 1024; // 5 MB\npub struct ChunkedBytes {\n  pub data: Bytes,\n  pub chunk_size: i32,\n  pub offsets: Vec<(usize, usize)>,\n}\n\nimpl Deref for ChunkedBytes {\n  type Target = Bytes;\n\n  fn deref(&self) -> &Self::Target {\n    &self.data\n  }\n}\n\nimpl ChunkedBytes {\n  pub fn from_bytes_with_chunk_size(data: Bytes, chunk_size: i32) -> Result<Self, anyhow::Error> {\n    if chunk_size < MIN_CHUNK_SIZE as i32 {\n      return Err(anyhow!(\n        \"Chunk size should be greater than or equal to {}\",\n        MIN_CHUNK_SIZE\n      ));\n    }\n\n    let offsets = split_into_chunks(&data, chunk_size as usize);\n    Ok(ChunkedBytes {\n      data,\n      offsets,\n      chunk_size,\n    })\n  }\n\n  /// Used to create a `ChunkedBytes` from a `Bytes` object. The default chunk size is 5 MB.\n  pub fn from_bytes(data: Bytes) -> Result<Self, anyhow::Error> {\n    let chunk_size = MIN_CHUNK_SIZE as i32;\n    let offsets = split_into_chunks(&data, MIN_CHUNK_SIZE);\n    Ok(ChunkedBytes {\n      data,\n      offsets,\n      chunk_size,\n    })\n  }\n\n  pub async fn from_file(file_path: &PathBuf, chunk_size: i32) -> Result<Self, anyhow::Error> {\n    let mut file = tokio::fs::File::open(file_path).await?;\n    let mut buffer = Vec::new();\n    file.read_to_end(&mut buffer).await?;\n    let data = Bytes::from(buffer);\n\n    let offsets = split_into_chunks(&data, chunk_size as usize);\n    Ok(ChunkedBytes {\n      data,\n      offsets,\n      chunk_size,\n    })\n  }\n\n  pub fn set_chunk_size(&mut self, chunk_size: i32) -> Result<(), anyhow::Error> {\n    if chunk_size < MIN_CHUNK_SIZE as i32 {\n      return Err(anyhow!(\n        \"Chunk size should be greater than or equal to {}\",\n        MIN_CHUNK_SIZE\n      ));\n    }\n\n    self.chunk_size = chunk_size;\n    self.offsets = split_into_chunks(&self.data, chunk_size as usize);\n    Ok(())\n  }\n\n  pub fn iter(&self) -> ChunkedBytesIterator {\n    ChunkedBytesIterator {\n      chunked_data: self,\n      current_index: 0,\n    }\n  }\n}\n\npub struct ChunkedBytesIterator<'a> {\n  chunked_data: &'a ChunkedBytes,\n  current_index: usize,\n}\nimpl Iterator for ChunkedBytesIterator<'_> {\n  type Item = Bytes;\n\n  fn next(&mut self) -> Option<Self::Item> {\n    if self.current_index >= self.chunked_data.offsets.len() {\n      None\n    } else {\n      let (start, end) = self.chunked_data.offsets[self.current_index];\n      self.current_index += 1;\n      Some(self.chunked_data.data.slice(start..end))\n    }\n  }\n}\n// Function to split input bytes into several chunks and return offsets\npub fn split_into_chunks(data: &Bytes, chunk_size: usize) -> Vec<(usize, usize)> {\n  let mut offsets = Vec::new();\n  let mut start = 0;\n\n  while start < data.len() {\n    let end = std::cmp::min(start + chunk_size, data.len());\n    offsets.push((start, end));\n    start = end;\n  }\n  offsets\n}\n\n// Function to get chunk data using chunk number\npub async fn get_chunk(\n  data: Bytes,\n  chunk_number: usize,\n  offsets: &[(usize, usize)],\n) -> Result<Bytes, anyhow::Error> {\n  if chunk_number >= offsets.len() {\n    return Err(anyhow!(\"Chunk number out of range\"));\n  }\n\n  let (start, end) = offsets[chunk_number];\n  let chunk = data.slice(start..end);\n\n  Ok(chunk)\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::file_util::{ChunkedBytes, MIN_CHUNK_SIZE};\n  use bytes::Bytes;\n  use std::env::temp_dir;\n  use tokio::io::AsyncWriteExt;\n\n  #[tokio::test]\n  async fn test_chunked_bytes_less_than_chunk_size() {\n    let data = Bytes::from(vec![0; 1024 * 1024]); // 1 MB of zeroes\n    let chunked_data =\n      ChunkedBytes::from_bytes_with_chunk_size(data.clone(), MIN_CHUNK_SIZE as i32).unwrap();\n\n    // Check if the offsets are correct\n    assert_eq!(chunked_data.offsets.len(), 1); // Should have 1 chunk\n    assert_eq!(chunked_data.offsets[0], (0, 1024 * 1024));\n\n    // Check if the data can be iterated correctly\n    let mut iter = chunked_data.iter();\n    assert_eq!(iter.next().unwrap().len(), 1024 * 1024);\n    assert!(iter.next().is_none());\n  }\n\n  #[tokio::test]\n  async fn test_chunked_bytes_from_bytes() {\n    let data = Bytes::from(vec![0; 15 * 1024 * 1024]); // 15 MB of zeroes\n    let chunked_data =\n      ChunkedBytes::from_bytes_with_chunk_size(data.clone(), MIN_CHUNK_SIZE as i32).unwrap();\n\n    // Check if the offsets are correct\n    assert_eq!(chunked_data.offsets.len(), 3); // Should have 3 chunks\n    assert_eq!(chunked_data.offsets[0], (0, 5 * 1024 * 1024));\n    assert_eq!(chunked_data.offsets[1], (5 * 1024 * 1024, 10 * 1024 * 1024));\n    assert_eq!(\n      chunked_data.offsets[2],\n      (10 * 1024 * 1024, 15 * 1024 * 1024)\n    );\n\n    // Check if the data can be iterated correctly\n    let mut iter = chunked_data.iter();\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert!(iter.next().is_none());\n  }\n\n  #[tokio::test]\n  async fn test_chunked_bytes_from_file() {\n    // Create a temporary file with 15 MB of zeroes\n    let mut file_path = temp_dir();\n    file_path.push(\"test_file\");\n\n    let mut file = tokio::fs::File::create(&file_path).await.unwrap();\n    file.write_all(&vec![0; 15 * 1024 * 1024]).await.unwrap();\n    file.flush().await.unwrap();\n\n    // Read the file into ChunkedBytes\n    let chunked_data = ChunkedBytes::from_file(&file_path, MIN_CHUNK_SIZE as i32)\n      .await\n      .unwrap();\n\n    // Check if the offsets are correct\n    assert_eq!(chunked_data.offsets.len(), 3); // Should have 3 chunks\n    assert_eq!(chunked_data.offsets[0], (0, 5 * 1024 * 1024));\n    assert_eq!(chunked_data.offsets[1], (5 * 1024 * 1024, 10 * 1024 * 1024));\n    assert_eq!(\n      chunked_data.offsets[2],\n      (10 * 1024 * 1024, 15 * 1024 * 1024)\n    );\n\n    // Check if the data can be iterated correctly\n    let mut iter = chunked_data.iter();\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert_eq!(iter.next().unwrap().len(), 5 * 1024 * 1024);\n    assert!(iter.next().is_none());\n\n    // Clean up the temporary file\n    tokio::fs::remove_file(file_path).await.unwrap();\n  }\n}\n"
  },
  {
    "path": "libs/infra/src/lib.rs",
    "content": "pub mod env_util;\n\n#[cfg(feature = \"file_util\")]\npub mod file_util;\n#[cfg(feature = \"request_util\")]\npub mod reqwest;\n\npub mod thread_pool;\n// pub mod tokio_runtime;\npub mod validate;\n"
  },
  {
    "path": "libs/infra/src/reqwest.rs",
    "content": "use anyhow::anyhow;\nuse anyhow::Error;\nuse bytes::{Bytes, BytesMut};\nuse futures::{ready, Stream};\nuse std::marker::PhantomData;\nuse std::pin::Pin;\n\nuse pin_project::pin_project;\nuse serde::de::DeserializeOwned;\nuse serde_json::de::SliceRead;\nuse serde_json::StreamDeserializer;\nuse std::error::Error as StdError;\nuse std::task::{Context, Poll};\nuse tracing::error;\n\npub async fn check_response(resp: reqwest::Response) -> Result<(), Error> {\n  let status_code = resp.status();\n  if !status_code.is_success() {\n    let body = resp.text().await?;\n    anyhow::bail!(\"got error code: {}, body: {}\", status_code, body)\n  }\n  resp.bytes().await?;\n  Ok(())\n}\n\npub async fn from_response<T>(resp: reqwest::Response) -> Result<T, Error>\nwhere\n  T: serde::de::DeserializeOwned,\n{\n  let status_code = resp.status();\n  if !status_code.is_success() {\n    let body = resp.text().await?;\n    anyhow::bail!(\"got error code: {}, body: {}\", status_code, body)\n  }\n  from_body(resp).await\n}\n\npub async fn from_body<T>(resp: reqwest::Response) -> Result<T, Error>\nwhere\n  T: serde::de::DeserializeOwned,\n{\n  let status_code = resp.status();\n  let bytes = resp.bytes().await?;\n  serde_json::from_slice(&bytes).map_err(|e| {\n    anyhow!(\n      \"deserialize error: {}, status: {}, body: {}\",\n      status_code,\n      e,\n      String::from_utf8_lossy(&bytes)\n    )\n  })\n}\n\n#[pin_project]\npub struct JsonStream<T, E, SE> {\n  #[pin]\n  stream: Pin<Box<dyn Stream<Item = Result<Bytes, E>> + Send>>,\n  buffer: Vec<u8>,\n  _marker: PhantomData<T>,\n  _marker_error: PhantomData<SE>,\n}\n\nimpl<T, E, SE> JsonStream<T, E, SE>\nwhere\n  E: From<SE> + From<serde_json::Error> + std::error::Error + Send + Sync + 'static,\n  SE: std::error::Error + Send + Sync + 'static,\n{\n  pub fn new<S>(stream: S) -> Self\n  where\n    S: Stream<Item = Result<Bytes, E>> + Send + 'static,\n  {\n    JsonStream {\n      stream: Box::pin(stream),\n      buffer: Vec::new(),\n      _marker: PhantomData,\n      _marker_error: PhantomData,\n    }\n  }\n}\n\nimpl<T, E, SE> Stream for JsonStream<T, E, SE>\nwhere\n  T: DeserializeOwned,\n  E: From<SE> + From<serde_json::Error> + std::error::Error + Send + Sync + 'static,\n  SE: std::error::Error + Send + Sync + 'static,\n{\n  type Item = Result<T, E>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let mut this = self.project();\n\n    loop {\n      // Poll for the next chunk of data from the underlying stream\n      match ready!(this.stream.as_mut().poll_next(cx)) {\n        Some(Ok(bytes)) => {\n          this.buffer.extend_from_slice(&bytes);\n\n          // Create a StreamDeserializer to deserialize the bytes into T\n          let mut de = StreamDeserializer::new(SliceRead::new(this.buffer));\n\n          // Check if there's a valid deserialized object in the stream\n          match de.next() {\n            Some(Ok(value)) => {\n              // Determine the offset of the successfully deserialized data\n              let offset = de.byte_offset();\n              // Drain the buffer up to the byte offset to remove the consumed bytes\n              this.buffer.drain(0..offset);\n              return Poll::Ready(Some(Ok(value)));\n            },\n            Some(Err(err)) => {\n              return if err.is_eof() {\n                // Wait for more data if EOF indicates incomplete data\n                Poll::Pending\n              } else {\n                error!(\"parse json stream failed: {:?}\", err);\n                Poll::Ready(Some(Err(err.into())))\n              };\n            },\n            None => {\n              // No complete object is ready, wait for more data\n              continue;\n            },\n          }\n        },\n        Some(Err(err)) => {\n          // Convert the error to SE\n          return Poll::Ready(Some(Err(err)));\n        },\n        None => {\n          // Stream has ended; handle any remaining data in the buffer\n          if this.buffer.is_empty() {\n            return Poll::Ready(None);\n          }\n\n          // Try to deserialize any remaining data in the buffer\n          let mut de = StreamDeserializer::new(SliceRead::new(this.buffer));\n          match de.next() {\n            Some(Ok(value)) => {\n              let offset = de.byte_offset();\n              this.buffer.drain(0..offset);\n              return Poll::Ready(Some(Ok(value)));\n            },\n            Some(Err(err)) if err.is_eof() => {\n              // If EOF and buffer is incomplete, return None to indicate stream end\n              return Poll::Ready(None);\n            },\n            Some(Err(err)) => {\n              // Return any other errors that occur during deserialization\n              return Poll::Ready(Some(Err(err.into())));\n            },\n            None => {\n              // No more data to process; end the stream\n              return Poll::Ready(None);\n            },\n          }\n        },\n      }\n    }\n  }\n}\n\n/// Represents a stream of text lines delimited by newlines.\n#[pin_project]\npub struct NewlineStream<E> {\n  #[pin]\n  stream: Pin<Box<dyn Stream<Item = Result<Bytes, E>> + Send>>,\n  buffer: BytesMut,\n  _marker: PhantomData<E>,\n}\n\nimpl<E> NewlineStream<E> {\n  pub fn new<S>(stream: S) -> Self\n  where\n    S: Stream<Item = Result<Bytes, E>> + Send + 'static,\n  {\n    NewlineStream {\n      stream: Box::pin(stream),\n      buffer: BytesMut::new(),\n      _marker: PhantomData,\n    }\n  }\n}\n\nimpl<E> Stream for NewlineStream<E>\nwhere\n  E: StdError + Send + Sync + 'static + From<std::string::FromUtf8Error>,\n{\n  type Item = Result<String, E>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let mut this = self.project();\n\n    loop {\n      match ready!(this.stream.as_mut().poll_next(cx)) {\n        Some(Ok(bytes)) => {\n          this.buffer.extend_from_slice(&bytes);\n          if let Some(pos) = this.buffer.iter().position(|&b| b == b'\\n') {\n            let line = this.buffer.split_to(pos + 1);\n            let line = &line[..line.len() - 1]; // Remove the newline character\n\n            match String::from_utf8(line.to_vec()) {\n              Ok(value) => return Poll::Ready(Some(Ok(value))),\n              Err(err) => return Poll::Ready(Some(Err(E::from(err)))),\n            }\n          }\n        },\n        Some(Err(err)) => return Poll::Ready(Some(Err(err))),\n        None => {\n          if !this.buffer.is_empty() {\n            match String::from_utf8(this.buffer.to_vec()) {\n              Ok(value) => {\n                this.buffer.clear();\n                return Poll::Ready(Some(Ok(value)));\n              },\n              Err(err) => return Poll::Ready(Some(Err(E::from(err)))),\n            }\n          } else {\n            return Poll::Ready(None);\n          }\n        },\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "libs/infra/src/thread_pool.rs",
    "content": "use std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::Arc;\n\nuse rayon::{ThreadPool, ThreadPoolBuilder};\nuse thiserror::Error;\n\n/// A thread pool that does not abort on panics.\n///\n/// This custom thread pool wraps Rayon’s `ThreadPool` and ensures that the thread pool\n/// can recover from panics gracefully. It detects any panics in worker threads and\n/// prevents the entire application from aborting.\n#[derive(Debug)]\npub struct ThreadPoolNoAbort {\n  /// Internal Rayon thread pool.\n  thread_pool: ThreadPool,\n  /// Atomic flag to detect if a panic occurred in the thread pool.\n  catched_panic: Arc<AtomicBool>,\n}\n\nimpl ThreadPoolNoAbort {\n  /// Executes a closure within the thread pool.\n  ///\n  /// This method runs the provided closure (`op`) inside the thread pool. If a panic\n  /// occurs during the execution, it is detected and returned as an error.\n  ///\n  /// # Arguments\n  /// * `op` - A closure that will be executed within the thread pool.\n  ///\n  /// # Returns\n  /// * `Ok(R)` - The result of the closure if execution was successful.\n  /// * `Err(PanicCatched)` - An error indicating that a panic occurred during execution.\n  ///\n  pub fn install<OP, R>(&self, op: OP) -> Result<R, CatchedPanic>\n  where\n    OP: FnOnce() -> R + Send,\n    R: Send,\n  {\n    let output = self.thread_pool.install(op);\n    // Reset the panic flag and return an error if a panic was detected.\n    if self.catched_panic.swap(false, Ordering::SeqCst) {\n      Err(CatchedPanic)\n    } else {\n      Ok(output)\n    }\n  }\n\n  /// Returns the current number of threads in the thread pool.\n  ///\n  /// # Returns\n  /// The number of threads being used by the thread pool.\n  pub fn current_num_threads(&self) -> usize {\n    self.thread_pool.current_num_threads()\n  }\n}\n\n/// Error indicating that a panic occurred during thread pool execution.\n///\n/// This error is returned when a closure executed in the thread pool panics.\n#[derive(Error, Debug)]\n#[error(\"A panic occurred happened in the thread pool. Check the logs for more information\")]\npub struct CatchedPanic;\n\n/// A builder for creating a `ThreadPoolNoAbort` instance.\n///\n/// This builder wraps Rayon’s `ThreadPoolBuilder` and customizes the panic handling behavior.\n#[derive(Default)]\npub struct ThreadPoolNoAbortBuilder(ThreadPoolBuilder);\n\nimpl ThreadPoolNoAbortBuilder {\n  pub fn new() -> ThreadPoolNoAbortBuilder {\n    ThreadPoolNoAbortBuilder::default()\n  }\n\n  /// Sets a custom naming function for threads in the pool.\n  ///\n  /// # Arguments\n  /// * `closure` - A function that takes a thread index and returns a thread name.\n  ///\n  pub fn thread_name<F>(mut self, closure: F) -> Self\n  where\n    F: FnMut(usize) -> String + 'static,\n  {\n    self.0 = self.0.thread_name(closure);\n    self\n  }\n\n  /// Sets the number of threads for the thread pool.\n  ///\n  /// # Arguments\n  /// * `num_threads` - The number of threads to create in the thread pool.\n  pub fn num_threads(mut self, num_threads: usize) -> ThreadPoolNoAbortBuilder {\n    self.0 = self.0.num_threads(num_threads);\n    self\n  }\n\n  /// Builds the `ThreadPoolNoAbort` instance.\n  ///\n  /// This method creates a `ThreadPoolNoAbort` with the specified configurations,\n  /// including custom panic handling behavior.\n  ///\n  /// # Returns\n  /// * `Ok(ThreadPoolNoAbort)` - The constructed thread pool.\n  /// * `Err(ThreadPoolBuildError)` - If the thread pool failed to build.\n  ///\n  pub fn build(mut self) -> Result<ThreadPoolNoAbort, rayon::ThreadPoolBuildError> {\n    let catched_panic = Arc::new(AtomicBool::new(false));\n    self.0 = self.0.panic_handler({\n      let catched_panic = catched_panic.clone();\n      move |_result| catched_panic.store(true, Ordering::SeqCst)\n    });\n    Ok(ThreadPoolNoAbort {\n      thread_pool: self.0.build()?,\n      catched_panic,\n    })\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use std::sync::atomic::{AtomicUsize, Ordering};\n\n  #[test]\n  fn test_install_closure_success() {\n    // Create a thread pool with 4 threads.\n    let pool = ThreadPoolNoAbortBuilder::new()\n      .num_threads(4)\n      .build()\n      .expect(\"Failed to build thread pool\");\n\n    // Run a closure that executes successfully.\n    let result = pool.install(|| 42);\n\n    // Ensure the result is correct.\n    assert!(result.is_ok());\n    assert_eq!(result.unwrap(), 42);\n  }\n\n  #[test]\n  fn test_multiple_threads_execution() {\n    // Create a thread pool with multiple threads.\n    let pool = ThreadPoolNoAbortBuilder::new()\n      .num_threads(8)\n      .build()\n      .expect(\"Failed to build thread pool\");\n\n    // Shared atomic counter to verify parallel execution.\n    let counter = Arc::new(AtomicUsize::new(0));\n\n    let handles: Vec<_> = (0..100)\n      .map(|_| {\n        let counter_clone = counter.clone();\n        pool.install(move || {\n          counter_clone.fetch_add(1, Ordering::SeqCst);\n        })\n      })\n      .collect();\n\n    // Ensure all tasks completed successfully.\n    for handle in handles {\n      assert!(handle.is_ok());\n    }\n\n    // Verify that the counter equals the number of tasks executed.\n    assert_eq!(counter.load(Ordering::SeqCst), 100);\n  }\n}\n"
  },
  {
    "path": "libs/infra/src/tokio_runtime.rs",
    "content": "use std::io;\nuse std::sync::LazyLock;\n\nuse tokio::runtime;\nuse tokio::runtime::Runtime;\n\npub static AF_SINGLE_THREAD_RUNTIME: LazyLock<Runtime> =\n  LazyLock::new(|| default_tokio_runtime().unwrap());\n\nfn default_tokio_runtime() -> io::Result<Runtime> {\n  runtime::Builder::new_multi_thread()\n    .worker_threads(1)\n    .thread_name(\"af-runtime\")\n    .enable_io()\n    .enable_time()\n    .build()\n}\n"
  },
  {
    "path": "libs/infra/src/validate.rs",
    "content": "use validator::ValidationError;\n\npub fn validate_not_empty_str(s: &str) -> Result<(), ValidationError> {\n  if s.is_empty() {\n    return Err(ValidationError::new(\"should not be empty string\"));\n  }\n  Ok(())\n}\n\npub fn validate_not_empty_payload(payload: &[u8]) -> Result<(), ValidationError> {\n  if payload.is_empty() {\n    return Err(ValidationError::new(\"should not be empty payload\"));\n  }\n  Ok(())\n}\n"
  },
  {
    "path": "libs/llm-client/Cargo.toml",
    "content": "[package]\nname = \"llm-client\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nasync-openai.workspace = true\ntracing.workspace = true\napp-error = { workspace = true, features = [\"appflowy_ai_error\"] }\nserde_json.workspace = true\nserde.workspace = true\nschemars = \"0.8.22\"\nuuid.workspace = true"
  },
  {
    "path": "libs/llm-client/src/chat.rs",
    "content": "use app_error::AppError;\nuse async_openai::config::{AzureConfig, Config, OpenAIConfig};\nuse async_openai::types::{\n  ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs,\n  CreateChatCompletionRequestArgs, ResponseFormat, ResponseFormatJsonSchema,\n};\nuse async_openai::Client;\nuse schemars::{schema_for, JsonSchema};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse tracing::{error, info, trace};\nuse uuid::Uuid;\n\npub enum AITool {\n  OpenAI(OpenAIChat),\n  AzureOpenAI(AzureOpenAIChat),\n}\n\nimpl AITool {\n  pub async fn summarize_documents(\n    &self,\n    question: &str,\n    model_name: &str,\n    documents: Vec<LLMDocument>,\n    only_context: bool,\n  ) -> Result<SummarySearchResponse, AppError> {\n    trace!(\n      \"Using model:{} to answer question:{}, with {} documents, only_context:{}\",\n      model_name,\n      question,\n      documents.len(),\n      only_context\n    );\n    match self {\n      AITool::OpenAI(client) => {\n        summarize_documents(\n          &client.client,\n          question,\n          model_name,\n          documents,\n          only_context,\n        )\n        .await\n      },\n      AITool::AzureOpenAI(client) => {\n        summarize_documents(\n          &client.client,\n          question,\n          model_name,\n          documents,\n          only_context,\n        )\n        .await\n      },\n    }\n  }\n}\n\npub struct OpenAIChat {\n  pub client: Client<OpenAIConfig>,\n}\n\nimpl OpenAIChat {\n  pub fn new(config: OpenAIConfig) -> Self {\n    let client = Client::with_config(config);\n    Self { client }\n  }\n}\n\npub struct AzureOpenAIChat {\n  pub client: Client<AzureConfig>,\n}\n\nimpl AzureOpenAIChat {\n  pub fn new(config: AzureConfig) -> Self {\n    let client = Client::with_config(config);\n    Self { client }\n  }\n}\n\n#[derive(Debug)]\npub struct SearchSummary {\n  pub content: String,\n  pub highlights: String,\n  pub sources: Vec<Uuid>,\n  pub score: f32,\n}\n\n#[derive(Debug)]\npub struct SummarySearchResponse {\n  pub summaries: Vec<SearchSummary>,\n}\n\n#[derive(Debug, Serialize, Deserialize, JsonSchema)]\n#[serde(deny_unknown_fields)]\nstruct SummarySearchSchema {\n  pub answer: String,\n  pub highlights: String,\n  pub score: String,\n  pub sources: Vec<String>,\n}\n\nconst SYSTEM_PROMPT: &str = r#\"\nYou are a concise, intelligent question-answering assistant.\n\nInstructions:\n- Use the provided context as the **primary basis** for your answer.\n- You **may incorporate relevant knowledge** only if it supports or enhances the context meaningfully.\n- The answer must be a **clear and concise**.\n\nOutput must include:\n- `answer`: a concise summary.\n- `highlights`:A markdown bullet list that highlights key themes and important details (e.g., date, time, location, etc.).\n- `score`: relevance score (0.0–1.0), where:\n  - 1.0 = fully supported by context,\n  - 0.0 = unsupported,\n  - values in between reflect partial support.\n- `sources`: array of source IDs used for the answer.\n\"#;\n\nconst ONLY_CONTEXT_SYSTEM_PROMPT: &str = r#\"\nYou are a strict, context-bound question answering assistant. Answer solely based on the text provided below. If the context lacks sufficient information for a confident response, reply with an empty answer.\n\nOutput must include:\n- `answer`: a concise answer.\n- `highlights`:A markdown bullet list that highlights key themes and important details (e.g., date, time, location, etc.).\n- `score`: relevance score (0.0–1.0), where:\n  - 1.0 = fully supported by context,\n  - 0.0 = unsupported,\n  - values in between reflect partial support.\n- `sources`: array of source IDs used for the answer.\n\nDo not reference or use any information beyond what is provided in the context.\n\"#;\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct LLMDocument {\n  pub content: String,\n  pub object_id: Uuid,\n}\n\nimpl LLMDocument {\n  pub fn new(content: String, object_id: Uuid) -> Self {\n    Self { content, object_id }\n  }\n}\n\nfn convert_documents_to_text(documents: Vec<LLMDocument>) -> String {\n  documents\n    .into_iter()\n    .map(|doc| json!(doc).to_string())\n    .collect::<Vec<String>>()\n    .join(\"\\n\")\n}\n\npub async fn summarize_documents<C: Config>(\n  client: &Client<C>,\n  question: &str,\n  model_name: &str,\n  documents: Vec<LLMDocument>,\n  only_context: bool,\n) -> Result<SummarySearchResponse, AppError> {\n  let documents_text = convert_documents_to_text(documents);\n  let context = if only_context {\n    format!(\n      \"{}\\n\\n##Context##\\n{}\",\n      ONLY_CONTEXT_SYSTEM_PROMPT, documents_text\n    )\n  } else {\n    SYSTEM_PROMPT.to_string()\n  };\n\n  let schema = schema_for!(SummarySearchSchema);\n  let schema_value = serde_json::to_value(&schema)?;\n  let response_format = ResponseFormat::JsonSchema {\n    json_schema: ResponseFormatJsonSchema {\n      description: Some(\n        \"A response containing a final answer, highlight, score and relevance sources\".to_string(),\n      ),\n      name: \"SummarySearchSchema\".into(),\n      schema: Some(schema_value),\n      strict: Some(true),\n    },\n  };\n\n  let request = CreateChatCompletionRequestArgs::default()\n    .model(model_name)\n    .messages([\n      ChatCompletionRequestSystemMessageArgs::default()\n        .content(context)\n        .build()?\n        .into(),\n      ChatCompletionRequestUserMessageArgs::default()\n        .content(question)\n        .build()?\n        .into(),\n    ])\n    .response_format(response_format)\n    .build()?;\n\n  let response = client\n    .chat()\n    .create(request)\n    .await?\n    .choices\n    .first()\n    .and_then(|choice| choice.message.content.clone())\n    .and_then(|content| serde_json::from_str::<SummarySearchSchema>(&content).ok())\n    .ok_or_else(|| AppError::Unhandled(\"No response from OpenAI\".to_string()))?;\n\n  trace!(\"AI summary search document response: {:?}\", response);\n  if response.answer.is_empty() {\n    return Ok(SummarySearchResponse { summaries: vec![] });\n  }\n\n  let score = match response.score.parse::<f32>() {\n    Ok(score) => score,\n    Err(err) => {\n      error!(\n        \"[Search] Failed to parse AI summary score: {}. Error: {}\",\n        response.score, err\n      );\n      0.0\n    },\n  };\n\n  // If only_context is true, we need to ensure the score is above a certain threshold.\n  if only_context && score < 0.4 {\n    info!(\n      \"[Search] AI summary score is too low: {}. Returning empty result.\",\n      score\n    );\n    return Ok(SummarySearchResponse { summaries: vec![] });\n  }\n\n  let summary = SearchSummary {\n    content: response.answer,\n    highlights: response.highlights,\n    sources: response\n      .sources\n      .into_iter()\n      .flat_map(|s| Uuid::parse_str(&s).ok())\n      .collect(),\n    score,\n  };\n\n  Ok(SummarySearchResponse {\n    summaries: vec![summary],\n  })\n}\n"
  },
  {
    "path": "libs/llm-client/src/lib.rs",
    "content": "pub mod chat;\n"
  },
  {
    "path": "libs/mailer/Cargo.toml",
    "content": "[package]\nname = \"mailer\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nlettre = { version = \"0.11.7\", features = [\"tokio1\", \"tokio1-native-tls\"] }\nanyhow.workspace = true\nserde.workspace = true\nhandlebars = \"5.1.2\"\nsecrecy.workspace = true"
  },
  {
    "path": "libs/mailer/src/config.rs",
    "content": "use secrecy::Secret;\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct MailerSetting {\n  pub smtp_host: String,\n  pub smtp_port: u16,\n  pub smtp_username: String,\n  pub smtp_email: String,\n  pub smtp_password: Secret<String>,\n  pub smtp_tls_kind: String,\n}\n"
  },
  {
    "path": "libs/mailer/src/lib.rs",
    "content": "pub mod config;\npub mod sender;\n"
  },
  {
    "path": "libs/mailer/src/sender.rs",
    "content": "use handlebars::Handlebars;\nuse lettre::message::header::ContentType;\nuse lettre::message::Message;\nuse lettre::transport::smtp::authentication::Credentials;\nuse lettre::transport::smtp::client::Tls;\nuse lettre::transport::smtp::client::TlsParameters;\nuse lettre::Address;\nuse lettre::AsyncSmtpTransport;\nuse lettre::AsyncTransport;\nuse secrecy::ExposeSecret;\n\n#[derive(Clone)]\npub struct Mailer {\n  smtp_transport: AsyncSmtpTransport<lettre::Tokio1Executor>,\n  smtp_email: String,\n  handlers: Handlebars<'static>,\n}\nimpl Mailer {\n  pub async fn new(\n    smtp_username: String,\n    smtp_email: String,\n    smtp_password: secrecy::Secret<String>,\n    smtp_host: &str,\n    smtp_port: u16,\n    smtp_tls_kind: &str,\n  ) -> Result<Self, anyhow::Error> {\n    let creds = Credentials::new(smtp_username, smtp_password.expose_secret().to_string());\n    let tls: Tls = match smtp_tls_kind {\n      \"none\" => Tls::None,\n      \"wrapper\" => Tls::Wrapper(TlsParameters::new(smtp_host.into())?),\n      \"required\" => Tls::Required(TlsParameters::new(smtp_host.into())?),\n      \"opportunistic\" => Tls::Opportunistic(TlsParameters::new(smtp_host.into())?),\n      _ => return Err(anyhow::anyhow!(\"Invalid TLS kind\")),\n    };\n\n    let smtp_transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(smtp_host)\n      .tls(tls)\n      .credentials(creds)\n      .port(smtp_port)\n      .build();\n    let handlers = Handlebars::new();\n    Ok(Self {\n      smtp_transport,\n      smtp_email,\n      handlers,\n    })\n  }\n\n  pub async fn register_template(\n    &mut self,\n    name: &str,\n    template: &str,\n  ) -> Result<(), anyhow::Error> {\n    self.handlers.register_template_string(name, template)?;\n    Ok(())\n  }\n\n  pub fn render<T>(&self, name: &str, param: &T) -> Result<String, anyhow::Error>\n  where\n    T: serde::Serialize,\n  {\n    let rendered = self.handlers.render(name, param)?;\n    Ok(rendered)\n  }\n\n  pub async fn send_email_template<T>(\n    &self,\n    recipient_name: Option<String>,\n    email: &str,\n    template_name: &str,\n    param: T,\n    subject: &str,\n  ) -> Result<(), anyhow::Error>\n  where\n    T: serde::Serialize,\n  {\n    let rendered = self.handlers.render(template_name, &param)?;\n    let email = Message::builder()\n      .from(lettre::message::Mailbox::new(\n        Some(\"AppFlowy Notification\".to_string()),\n        self.smtp_email.parse::<Address>()?,\n      ))\n      .to(lettre::message::Mailbox::new(\n        recipient_name,\n        email.parse()?,\n      ))\n      .subject(subject)\n      .header(ContentType::TEXT_HTML)\n      .body(rendered)?;\n\n    AsyncTransport::send(&self.smtp_transport, email).await?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "libs/shared-entity/Cargo.toml",
    "content": "[package]\nname = \"shared-entity\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nanyhow.workspace = true\nserde = \"1.0\"\nserde_json.workspace = true\nserde_repr = \"0.1.18\"\nthiserror = \"1.0.56\"\nreqwest = { workspace = true, features = [\"stream\"] }\nuuid = { version = \"1.6.1\", features = [\"v4\"] }\ngotrue-entity = { path = \"../gotrue-entity\" }\ndatabase-entity.workspace = true\ninfra = { workspace = true, features = [\"request_util\"] }\ncollab-entity = { workspace = true }\napp-error = { workspace = true }\nchrono = \"0.4.31\"\nappflowy-ai-client = { workspace = true, default-features = false, features = [\n  \"dto\",\n] }\npin-project = \"1.1.5\"\n\nactix-web = { version = \"4.4.1\", default-features = false, features = [\n  \"http2\",\n], optional = true }\nvalidator = { workspace = true, features = [\"validator_derive\", \"derive\"] }\nfutures = \"0.3.30\"\nbytes.workspace = true\n\n\n[features]\ncloud = [\"actix-web\"]\n"
  },
  {
    "path": "libs/shared-entity/src/dto/access_request_dto.rs",
    "content": "use database_entity::dto::{AFWorkspace, AccessRequestStatus, AccessRequesterInfo};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::workspace_dto::{ViewIcon, ViewLayout};\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AccessRequestView {\n  pub view_id: String,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub layout: ViewLayout,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct AccessRequest {\n  pub request_id: Uuid,\n  pub workspace: AFWorkspace,\n  pub requester: AccessRequesterInfo,\n  pub view: AccessRequestView,\n  pub status: AccessRequestStatus,\n  pub created_at: chrono::DateTime<chrono::Utc>,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/ai_dto.rs",
    "content": "use crate::dto::chat_dto::ChatMessage;\npub use appflowy_ai_client::dto::*;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Map, Value};\nuse uuid::Uuid;\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SummarizeRowParams {\n  pub workspace_id: Uuid,\n  pub data: SummarizeRowData,\n}\n\n/// Represents different types of content that can be used to summarize a database row.\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub enum SummarizeRowData {\n  /// Specifies the identity of the row within the database.\n  Identity { database_id: String, row_id: String },\n  /// Content of the row provided as key-value pairs.\n  /// For example:\n  /// ```json\n  /// {\n  ///  \"name\": \"Jack\",\n  ///  \"age\": 25,\n  ///  \"city\": \"New York\"\n  /// }\n  Content(Map<String, Value>),\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SummarizeRowResponse {\n  pub text: String,\n}\n\n#[derive(Debug)]\npub enum StringOrMessage {\n  Left(String),\n  Right(ChatMessage),\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/auth_dto.rs",
    "content": "// Data Transfer Objects (DTO)\n\nuse gotrue_entity::dto::GotrueTokenResponse;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n#[derive(Deserialize, Serialize)]\npub struct SignInParams {\n  pub email: String,\n  pub password: String,\n}\n\n#[derive(Default, Deserialize, Serialize, Clone)]\npub struct UserMetaData(HashMap<String, serde_json::Value>);\nimpl UserMetaData {\n  pub fn new() -> Self {\n    Self::default()\n  }\n  pub fn into_inner(self) -> HashMap<String, serde_json::Value> {\n    self.0\n  }\n\n  pub fn insert<T: Into<serde_json::Value>>(&mut self, key: &str, value: T) {\n    self.0.insert(key.to_string(), value.into());\n  }\n}\n\n#[derive(serde::Deserialize, serde::Serialize, Default)]\npub struct UpdateUserParams {\n  pub name: Option<String>,\n  pub password: Option<String>,\n  pub email: Option<String>,\n  pub metadata: Option<UserMetaData>,\n}\n\nimpl UpdateUserParams {\n  pub fn new() -> Self {\n    Self::default()\n  }\n  pub fn with_password<T: ToString>(mut self, password: T) -> Self {\n    self.password = Some(password.to_string());\n    self\n  }\n  pub fn with_name<T: ToString>(mut self, name: T) -> Self {\n    self.name = Some(name.to_string());\n    self\n  }\n  pub fn with_email<T: ToString>(mut self, email: T) -> Self {\n    self.email = Some(email.to_string());\n    self\n  }\n  pub fn with_metadata<T: Into<UserMetaData>>(mut self, metadata: T) -> Self {\n    self.metadata = Some(metadata.into());\n    self\n  }\n}\n\n#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]\npub struct SignInPasswordResponse {\n  pub gotrue_response: GotrueTokenResponse,\n  pub is_new: bool,\n}\n\n#[derive(serde::Deserialize, serde::Serialize)]\npub struct SignInTokenResponse {\n  pub is_new: bool,\n}\n\n#[derive(Debug, serde::Deserialize, serde::Serialize)]\npub struct DeleteUserQuery {\n  pub provider_access_token: Option<String>,\n  pub provider_refresh_token: Option<String>,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/billing_dto.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse std::fmt::Display;\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum RecurringInterval {\n  Month = 0,\n  Year = 1,\n}\n\nimpl RecurringInterval {\n  pub fn as_str(&self) -> &str {\n    match self {\n      RecurringInterval::Month => \"month\",\n      RecurringInterval::Year => \"year\",\n    }\n  }\n}\n\nimpl TryFrom<i16> for RecurringInterval {\n  type Error = String;\n\n  fn try_from(value: i16) -> Result<Self, Self::Error> {\n    match value {\n      0 => Ok(RecurringInterval::Month),\n      1 => Ok(RecurringInterval::Year),\n      _ => Err(format!(\"Invalid RecurringInterval value: {}\", value)),\n    }\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\n#[repr(i16)]\npub enum SubscriptionPlan {\n  Free = 0,\n  Pro = 1,\n  Team = 2,\n\n  AiMax = 3,\n  AiLocal = 4,\n}\n\nimpl TryFrom<i16> for SubscriptionPlan {\n  type Error = String;\n\n  fn try_from(value: i16) -> Result<Self, Self::Error> {\n    match value {\n      0 => Ok(SubscriptionPlan::Free),\n      1 => Ok(SubscriptionPlan::Pro),\n      2 => Ok(SubscriptionPlan::Team),\n      3 => Ok(SubscriptionPlan::AiMax),\n      4 => Ok(SubscriptionPlan::AiLocal),\n      _ => Err(format!(\"Invalid SubscriptionPlan value: {}\", value)),\n    }\n  }\n}\n\nimpl AsRef<str> for SubscriptionPlan {\n  fn as_ref(&self) -> &str {\n    match self {\n      SubscriptionPlan::Free => \"free\",\n      SubscriptionPlan::Pro => \"pro\",\n      SubscriptionPlan::Team => \"team\",\n      SubscriptionPlan::AiMax => \"ai_max\",\n      SubscriptionPlan::AiLocal => \"ai_local\",\n    }\n  }\n}\n\nimpl TryFrom<&str> for SubscriptionPlan {\n  type Error = String;\n\n  fn try_from(value: &str) -> Result<Self, Self::Error> {\n    match value {\n      \"free\" => Ok(SubscriptionPlan::Free),\n      \"pro\" => Ok(SubscriptionPlan::Pro),\n      \"team\" => Ok(SubscriptionPlan::Team),\n      \"ai_max\" => Ok(SubscriptionPlan::AiMax),\n      \"ai_local\" => Ok(SubscriptionPlan::AiLocal),\n      _ => Err(format!(\"Invalid SubscriptionPlan value: {}\", value)),\n    }\n  }\n}\n\n#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]\n#[serde(rename_all = \"snake_case\")]\npub enum SubscriptionStatus {\n  Active,\n  Canceled,\n  Incomplete,\n  IncompleteExpired,\n  PastDue,\n  Paused,\n  Trialing,\n  Unpaid,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct WorkspaceSubscriptionStatus {\n  pub workspace_id: String,\n  pub workspace_plan: SubscriptionPlan,\n  pub recurring_interval: RecurringInterval,\n  pub subscription_status: SubscriptionStatus,\n  pub subscription_quantity: u64,\n  pub cancel_at: Option<i64>,\n  pub current_period_end: i64,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct WorkspaceUsageAndLimit {\n  pub member_count: i64,\n  pub member_count_limit: i64,\n  pub storage_bytes: i64,\n  pub storage_bytes_limit: i64,\n  pub storage_bytes_unlimited: bool,\n  pub single_upload_limit: i64,\n  pub single_upload_unlimited: bool,\n  pub ai_responses_count: i64,\n  pub ai_responses_count_limit: i64,\n  #[serde(default)]\n  pub ai_image_responses_count: i64,\n  #[serde(default)]\n  pub ai_image_responses_count_limit: i64,\n\n  pub local_ai: bool,\n  pub ai_responses_unlimited: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SubscriptionCancelRequest {\n  pub workspace_id: String,\n  pub plan: SubscriptionPlan,\n  pub sync: bool, // if true, this request will block until stripe has sent the cancellation webhook\n  pub reason: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SetSubscriptionRecurringInterval {\n  pub workspace_id: String,\n  pub plan: SubscriptionPlan,\n  pub recurring_interval: RecurringInterval,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SubscriptionPlanDetail {\n  pub currency: Currency,\n  pub price_cents: i64,\n  pub recurring_interval: RecurringInterval,\n  pub plan: SubscriptionPlan,\n}\n\n#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, Default)]\npub enum Currency {\n  #[default]\n  USD,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct SubscriptionLinkRequest {\n  pub workspace_subscription_plan: SubscriptionPlan,\n  pub recurring_interval: RecurringInterval,\n  pub workspace_id: String,\n  pub success_url: String,\n  pub with_test_clock: Option<bool>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct SubscriptionTrialRequest {\n  pub plan: SubscriptionPlan,\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub period_days: Option<u32>,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct LicensedProductDetail {\n  pub name: String,\n  pub currency: Currency,\n  pub price_cents: i64,\n  pub recurring_interval: RecurringInterval,\n  pub product_type: LicensedProductType,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserSubscribeProduct {\n  pub email: String,\n  pub product_ids: Vec<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct SubscribeProductLicense {\n  pub product_id: String,\n  pub policy_id: String,\n  pub license_id: String,\n  pub metadata: serde_json::Value,\n  pub max_machines: i32,\n  pub unlimited_devices: bool,\n  pub expires_at: Option<DateTime<Utc>>,\n}\n\n#[derive(Debug, Clone, serde_repr::Serialize_repr, serde_repr::Deserialize_repr)]\n#[repr(u8)]\npub enum LicensedProductType {\n  Unknown = 0,\n  AppFlowyAI = 1,\n  AppFlowyCloudPremium = 2,\n}\n\nimpl Display for LicensedProductType {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(f, \"{}\", (self.clone() as u8))\n  }\n}\n\nimpl TryFrom<&str> for LicensedProductType {\n  type Error = String;\n\n  fn try_from(value: &str) -> Result<Self, Self::Error> {\n    match value {\n      \"1\" => Ok(LicensedProductType::AppFlowyAI),\n      \"2\" => Ok(LicensedProductType::AppFlowyCloudPremium),\n      _ => Err(format!(\"Invalid LicensedProductType value: {}\", value)),\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize)]\npub struct LicenseProductSubscriptionLinkQuery {\n  pub product_type: LicensedProductType,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/chat_dto.rs",
    "content": "use chrono::{DateTime, Utc};\nuse infra::validate::validate_not_empty_str;\nuse serde::{Deserialize, Serialize};\n\nuse serde_json::json;\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse uuid::Uuid;\nuse validator::Validate;\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct CreateChatParams {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub chat_id: String,\n  pub name: String,\n  pub rag_ids: Vec<Uuid>,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct UpdateChatParams {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub name: Option<String>,\n\n  /// Key-value pairs of metadata to be updated.\n  pub metadata: Option<serde_json::Value>,\n\n  pub rag_ids: Option<Vec<String>>,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct CreateChatMessageParams {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub content: String,\n  pub message_type: ChatMessageType,\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub prompt_id: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatMetadataDescription {\n  pub id: String,\n  pub name: String,\n  pub source: String,\n  pub extra: Option<serde_json::Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatRAGData {\n  /// The textual content of the metadata. This field can contain raw text data from a specific\n  /// document or any other text content that is indexable. This content is typically used for\n  /// search and indexing purposes within the chat context.\n  pub content: String,\n\n  /// The type of content represented by this metadata. This could indicate the format or\n  /// nature of the content (e.g., text, markdown, PDF). The `content_type` helps in\n  /// processing or rendering the content appropriately.\n  pub content_type: ContextLoader,\n\n  /// The size of the content in bytes.\n  pub size: i64,\n}\n\nimpl ChatRAGData {\n  pub fn from_text(text: String) -> Self {\n    let size = text.len() as i64;\n    Self {\n      content: text,\n      content_type: ContextLoader::Text,\n      size,\n    }\n  }\n}\n\nimpl ChatRAGData {\n  /// Validates the `ChatMetadataData` instance.\n  ///\n  /// This method checks the validity of the data based on the content type and the presence of content or URL.\n  /// - If `content` is empty, the method checks if `url` is provided. If `url` is also empty, the data is invalid.\n  /// - For `Text` and `Markdown`, it ensures that the content length matches the specified size if content is present.\n  /// - For `Unknown` and `PDF`, it currently returns `false` as these types are either unsupported or\n  ///   require additional validation logic.\n  ///\n  /// Returns `true` if the data is valid according to its content type and the presence of content or URL, otherwise `false`.\n  pub fn validate(&self) -> Result<(), anyhow::Error> {\n    match self.content_type {\n      ContextLoader::Text | ContextLoader::Markdown => {\n        if self.content.len() != self.size as usize {\n          return Err(anyhow::anyhow!(\n            \"Invalid content size: content size: {}, expected size: {}\",\n            self.content.len(),\n            self.size\n          ));\n        }\n      },\n      ContextLoader::PDF => {\n        if self.content.is_empty() {\n          return Err(anyhow::anyhow!(\"Invalid content: content is empty\"));\n        }\n      },\n      ContextLoader::Unknown => {\n        return Err(anyhow::anyhow!(\n          \"Unsupported content type: {:?}\",\n          self.content_type\n        ));\n      },\n    }\n    Ok(())\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum ContextLoader {\n  Unknown,\n  Text,\n  Markdown,\n  PDF,\n}\n\nimpl Display for ContextLoader {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ContextLoader::Unknown => write!(f, \"unknown\"),\n      ContextLoader::Text => write!(f, \"text\"),\n      ContextLoader::Markdown => write!(f, \"markdown\"),\n      ContextLoader::PDF => write!(f, \"pdf\"),\n    }\n  }\n}\n\nimpl ChatRAGData {\n  pub fn new_text(content: String) -> Self {\n    let size = content.len();\n    Self {\n      content,\n      content_type: ContextLoader::Text,\n      size: size as i64,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateChatMessageMetaParams {\n  pub message_id: i64,\n  pub meta_data: HashMap<String, String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateChatMessageContentParams {\n  pub chat_id: String,\n  pub message_id: i64,\n  pub content: String,\n  #[serde(default)]\n  pub model: String,\n}\n\n#[derive(Debug, Clone, Default, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum ChatMessageType {\n  System = 0,\n  #[default]\n  User = 1,\n}\n\nimpl CreateChatMessageParams {\n  pub fn new_system<T: ToString>(content: T) -> Self {\n    Self {\n      content: content.to_string(),\n      message_type: ChatMessageType::System,\n      prompt_id: None,\n    }\n  }\n\n  pub fn new_user<T: ToString>(content: T) -> Self {\n    Self {\n      content: content.to_string(),\n      message_type: ChatMessageType::User,\n      prompt_id: None,\n    }\n  }\n}\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct GetChatMessageParams {\n  pub cursor: MessageCursor,\n  pub limit: u64,\n}\n\nimpl GetChatMessageParams {\n  pub fn offset(offset: u64, limit: u64) -> Self {\n    Self {\n      cursor: MessageCursor::Offset(offset),\n      limit,\n    }\n  }\n\n  pub fn after_message_id(after_message_id: i64, limit: u64) -> Self {\n    Self {\n      cursor: MessageCursor::AfterMessageId(after_message_id),\n      limit,\n    }\n  }\n  pub fn before_message_id(before_message_id: i64, limit: u64) -> Self {\n    Self {\n      cursor: MessageCursor::BeforeMessageId(before_message_id),\n      limit,\n    }\n  }\n\n  pub fn next_back(limit: u64) -> Self {\n    Self {\n      cursor: MessageCursor::NextBack,\n      limit,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum MessageCursor {\n  Offset(u64),\n  AfterMessageId(i64),\n  BeforeMessageId(i64),\n  NextBack,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatMessage {\n  pub author: ChatAuthor,\n  pub message_id: i64,\n  pub content: String,\n  pub created_at: DateTime<Utc>,\n  #[serde(rename = \"meta_data\")]\n  pub metadata: serde_json::Value,\n  /// When current message is a question, then reply_message_id is None\n  /// When current message is an answer, then reply_message_id is the question message id\n  pub reply_message_id: Option<i64>,\n}\n\nimpl ChatMessage {\n  pub fn new_human(message_id: i64, content: String, reply_message_id: Option<i64>) -> Self {\n    Self {\n      author: ChatAuthor::new(message_id, ChatAuthorType::Human),\n      message_id,\n      content,\n      created_at: Utc::now(),\n      metadata: json!({}),\n      reply_message_id,\n    }\n  }\n\n  pub fn new_ai(message_id: i64, content: String, reply_message_id: Option<i64>) -> Self {\n    Self {\n      author: ChatAuthor::ai(),\n      message_id,\n      content,\n      created_at: Utc::now(),\n      metadata: json!({}),\n      reply_message_id,\n    }\n  }\n\n  pub fn new_system(message_id: i64, content: String) -> Self {\n    Self {\n      author: ChatAuthor::new(message_id, ChatAuthorType::System),\n      message_id,\n      content,\n      created_at: Utc::now(),\n      metadata: json!({}),\n      reply_message_id: None,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatMessageWithAuthorUuid {\n  pub author: ChatAuthorWithUuid,\n  pub message_id: i64,\n  pub content: String,\n  #[serde(rename = \"meta_data\")]\n  pub metadata: serde_json::Value,\n  pub created_at: DateTime<Utc>,\n  pub reply_message_id: Option<i64>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct QAChatMessage {\n  pub question: ChatMessage,\n  pub answer: Option<ChatMessage>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RepeatedChatMessage {\n  pub messages: Vec<ChatMessage>,\n  pub has_more: bool,\n  pub total: i64,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RepeatedChatMessageWithAuthorUuid {\n  pub messages: Vec<ChatMessageWithAuthorUuid>,\n  pub has_more: bool,\n  pub total: i64,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatSettings {\n  // Currently we have not used the `name` field in the ChatSettings\n  pub name: String,\n  pub rag_ids: Vec<String>,\n  pub metadata: serde_json::Value,\n}\n\n#[derive(Debug, Default, Clone, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum ChatAuthorType {\n  Unknown = 0,\n  Human = 1,\n  #[default]\n  System = 2,\n  AI = 3,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatAuthor {\n  pub author_id: i64,\n  #[serde(default)]\n  pub author_type: ChatAuthorType,\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub meta: Option<serde_json::Value>,\n}\n\nimpl ChatAuthor {\n  pub fn new(author_id: i64, author_type: ChatAuthorType) -> Self {\n    Self {\n      author_id,\n      author_type,\n      meta: None,\n    }\n  }\n\n  pub fn ai() -> Self {\n    Self {\n      author_id: 0,\n      author_type: ChatAuthorType::AI,\n      meta: None,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ChatAuthorWithUuid {\n  pub author_id: i64,\n  pub author_uuid: Uuid,\n  #[serde(default)]\n  pub author_type: ChatAuthorType,\n  #[serde(default)]\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub meta: Option<serde_json::Value>,\n}\n\nimpl ChatAuthorWithUuid {\n  pub fn new(author_id: i64, author_uuid: Uuid, author_type: ChatAuthorType) -> Self {\n    Self {\n      author_id,\n      author_uuid,\n      author_type,\n      meta: None,\n    }\n  }\n\n  pub fn ai() -> Self {\n    Self {\n      author_id: 0,\n      author_uuid: Uuid::nil(),\n      author_type: ChatAuthorType::AI,\n      meta: None,\n    }\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateChatMessageResponse {\n  pub answer: Option<ChatMessage>,\n}\n\n#[derive(Debug, Clone, Validate, Serialize, Deserialize)]\npub struct CreateAnswerMessageParams {\n  #[validate(custom(function = \"validate_not_empty_str\"))]\n  pub content: String,\n\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub metadata: Option<serde_json::Value>,\n\n  pub question_message_id: i64,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/file_dto.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PutFileResponse {\n  pub file_id: String,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/guest_dto.rs",
    "content": "use chrono::{DateTime, Utc};\nuse database_entity::dto::{AFAccessLevel, AFRole};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\nuse super::workspace_dto::{ViewIcon, ViewLayout};\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SharedView {\n  pub view_id: Uuid,\n  pub access_level: AFAccessLevel,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SharedUser {\n  pub view_id: Uuid,\n  pub email: String,\n  pub name: String,\n  pub access_level: AFAccessLevel,\n  pub role: AFRole,\n  pub avatar_url: Option<String>,\n  pub pending_invitation: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SharedViewDetails {\n  pub view_id: Uuid,\n  pub shared_with: Vec<SharedUser>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SharedViewDetailsRequest {\n  pub ancestor_view_ids: Vec<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct SharedViews {\n  pub shared_views: Vec<SharedView>,\n  pub view_id_with_no_access: Vec<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RevokedAccess {\n  pub email: String,\n  pub view_id: Uuid,\n}\n\n#[derive(Clone)]\npub struct GuestInviteCode {\n  pub code: String,\n  pub email: String,\n}\n\npub struct SharedFolderView {\n  pub view_id: String,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub is_published: bool,\n  pub layout: ViewLayout,\n  pub created_at: DateTime<Utc>,\n  pub last_edited_time: DateTime<Utc>,\n  pub is_locked: Option<bool>,\n  /// contains fields like `is_space`, and font information\n  pub extra: Option<serde_json::Value>,\n  pub children: Vec<SharedFolderView>,\n  pub permission: AFAccessLevel,\n}\n\n#[derive(Serialize, Deserialize, Debug)]\npub struct ShareViewWithGuestRequest {\n  pub view_id: Uuid,\n  pub emails: Vec<String>,\n  pub access_level: AFAccessLevel,\n  // If false, the guest will need to accept the invitation before being added officially.\n  // to the workspace.\n  #[serde(default)]\n  pub auto_confirm: bool,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct RevokeSharedViewAccessRequest {\n  pub emails: Vec<String>,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/history_dto.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, PartialEq, Serialize, Deserialize)]\npub struct SnapshotMeta {\n  pub oid: String,\n  /// Using yrs::Snapshot to deserialize the snapshot\n  pub snapshot: Vec<u8>,\n  /// Specifies the version of the snapshot\n  pub snapshot_version: i32,\n  pub created_at: i64,\n}\n\n#[derive(Clone, PartialEq, Serialize, Deserialize)]\npub struct RepeatedSnapshotMeta {\n  pub items: Vec<SnapshotMeta>,\n}\n\n#[derive(Clone, PartialEq, Serialize, Deserialize)]\npub struct HistoryState {\n  pub object_id: String,\n  pub doc_state: Vec<u8>,\n  pub doc_state_version: i32,\n}\n\n/// [HistoryState] contains all the necessary information that can be used to restore the full state\n/// collab. This collab object can restore given [SnapshotMeta]. The snapshot represents the state of\n/// the collab at a certain point in time.\n#[derive(Clone, PartialEq, Serialize, Deserialize)]\npub struct SnapshotInfo {\n  pub history: HistoryState,\n  pub snapshot_meta: SnapshotMeta,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/import_dto.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct UserImportTask {\n  pub tasks: Vec<ImportTaskDetail>,\n  pub has_more: bool,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct ImportTaskDetail {\n  pub task_id: String,\n  pub file_size: u64,\n  pub created_at: i64,\n  pub status: i16,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/mod.rs",
    "content": "pub mod access_request_dto;\npub mod ai_dto;\npub mod auth_dto;\npub mod billing_dto;\npub mod chat_dto;\npub mod file_dto;\npub mod guest_dto;\npub mod history_dto;\npub mod import_dto;\npub mod publish_dto;\npub mod search_dto;\npub mod server_info_dto;\npub mod workspace_dto;\n"
  },
  {
    "path": "libs/shared-entity/src/dto/publish_dto.rs",
    "content": "use std::collections::HashMap;\n\nuse super::workspace_dto::{ViewIcon, ViewLayout};\nuse serde::{Deserialize, Serialize};\nuse uuid::Uuid;\n\n/// Copied from AppFlowy-IO/AppFlowy/frontend/rust-lib/flowy-folder-pub/src/entities.rs\n/// TODO(zack): make AppFlowy use from this crate instead\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct PublishViewMeta {\n  pub metadata: PublishViewMetaData,\n  pub view_id: String,\n  pub publish_name: String,\n}\n\n#[derive(Default, Deserialize, Serialize, Clone, Debug, Eq, PartialEq)]\npub struct PublishViewMetaData {\n  pub view: PublishViewInfo,\n  pub child_views: Vec<PublishViewInfo>,\n  pub ancestor_views: Vec<PublishViewInfo>,\n}\n\n#[derive(Default, Deserialize, Serialize, Clone, Debug, Eq, PartialEq)]\npub struct PublishViewInfo {\n  pub view_id: String,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub layout: ViewLayout,\n  pub extra: Option<String>,\n  pub created_by: Option<i64>,\n  pub last_edited_by: Option<i64>,\n  pub last_edited_time: i64,\n  pub created_at: i64,\n  pub child_views: Option<Vec<PublishViewInfo>>,\n}\n\n#[derive(Clone, Debug, Eq, PartialEq)]\npub struct PublishDatabasePayload {\n  pub meta: PublishViewMeta,\n  pub data: PublishDatabaseData,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]\npub struct PublishDatabaseData {\n  /// The encoded collab data for the database itself\n  pub database_collab: Vec<u8>,\n\n  /// The encoded collab data for the database rows\n  /// Use the row_id as the key\n  pub database_row_collabs: HashMap<Uuid, Vec<u8>>,\n\n  /// The encoded collab data for the documents inside the database rows\n  /// It's not used for now\n  pub database_row_document_collabs: HashMap<Uuid, Vec<u8>>,\n\n  /// Visible view ids\n  pub visible_database_view_ids: Vec<Uuid>,\n\n  /// Relation view id map\n  pub database_relations: HashMap<Uuid, Uuid>,\n}\n\n/// For fixing invalid published template with non-uuid relations\n#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq)]\npub struct PublishDatabaseDataWithNonUuidRelations {\n  /// The encoded collab data for the database itself\n  pub database_collab: Vec<u8>,\n\n  /// The encoded collab data for the database rows\n  /// Use the row_id as the key\n  pub database_row_collabs: HashMap<Uuid, Vec<u8>>,\n\n  /// The encoded collab data for the documents inside the database rows\n  /// It's not used for now\n  pub database_row_document_collabs: HashMap<Uuid, Vec<u8>>,\n\n  /// Visible view ids\n  pub visible_database_view_ids: Vec<Uuid>,\n\n  /// Relation view id map. Usually database relation ids should only consist\n  /// of uuids. However, it might be possible for a published database to contain\n  /// relations which are non uuids, which we will need to filter out later.\n  pub database_relations: HashMap<String, String>,\n}\n\nimpl From<PublishDatabaseDataWithNonUuidRelations> for PublishDatabaseData {\n  fn from(data: PublishDatabaseDataWithNonUuidRelations) -> Self {\n    PublishDatabaseData {\n      database_collab: data.database_collab,\n      database_row_collabs: data.database_row_collabs,\n      database_row_document_collabs: data.database_row_document_collabs,\n      visible_database_view_ids: data.visible_database_view_ids,\n      // Filter out all non-uuid relations\n      database_relations: data\n        .database_relations\n        .into_iter()\n        .filter_map(|(key, value)| {\n          let key_uuid = Uuid::parse_str(&key).ok()?;\n          let value_uuid = Uuid::parse_str(&value).ok()?;\n          Some((key_uuid, value_uuid))\n        })\n        .collect(),\n    }\n  }\n}\n\n#[derive(Default, Deserialize, Serialize, Clone, Debug, Eq, PartialEq)]\npub struct DuplicatePublishedPageResponse {\n  pub view_id: Uuid,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/search_dto.rs",
    "content": "use chrono::{DateTime, Utc};\nuse serde::{Deserialize, Serialize};\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse uuid::Uuid;\n\n/// Parameters used to customize the collab vector search query.\n/// In response, a list of [SearchDocumentResponseItem] is returned.\n#[derive(Clone, Debug, Deserialize)]\npub struct SearchDocumentRequest {\n  /// Query statement to search for.\n  pub query: String,\n  /// Maximum number of results to return. Default: 10.\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub limit: Option<u32>,\n  /// Maximum length of the content string preview to return. Default: 180.\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub preview_size: Option<u32>,\n\n  #[serde(default = \"default_search_score_limit\")]\n  pub score: f64,\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct SearchResult {\n  pub object_id: Uuid,\n  pub content: String,\n}\n\nimpl From<&SearchDocumentResponseItem> for SearchResult {\n  fn from(value: &SearchDocumentResponseItem) -> Self {\n    Self {\n      object_id: value.object_id,\n      content: value.content.clone(),\n    }\n  }\n}\n\n#[derive(Clone, Debug, Deserialize, Serialize)]\npub struct SummarySearchResultRequest {\n  pub query: String,\n\n  pub search_results: Vec<SearchResult>,\n\n  pub only_context: bool,\n}\n\nfn default_search_score_limit() -> f64 {\n  // Higher score means better match.\n  0.2\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct Summary {\n  pub content: String,\n  #[serde(default)]\n  pub highlights: String,\n  pub sources: Vec<Uuid>,\n}\n\n/// Response array element for the collab vector search query.\n/// See: [SearchDocumentRequest].\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SearchSummaryResult {\n  pub summaries: Vec<Summary>,\n}\n\n/// Response array element for the collab vector search query.\n/// See: [SearchDocumentRequest].\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct SearchDocumentResponseItem {\n  /// Unique object identifier.\n  pub object_id: Uuid,\n  /// Workspace, result object belongs to.\n  pub workspace_id: Uuid,\n  /// Match score of this search result to an original query. Score represents cosine distance\n  /// between the query and the document embedding [-1.0..1.0]. The higher, the better.\n  /// List of results is sorted by this value by default.\n  pub score: f64,\n  /// Type of the content to be presented in preview field. This is a hint what\n  /// kind of content was used to match the user query ie. document plain text, pdf attachment etc.\n  pub content_type: Option<SearchContentType>,\n  /// Content of the document. This is a full content of the document, not just a preview.\n  #[serde(default)]\n  pub content: String,\n  /// First N characters of the indexed content matching the user query. It doesn't have to contain\n  /// the user query itself.\n  pub preview: Option<String>,\n  /// Name of the user who created/own the document.\n  pub created_by: String,\n  /// Date when the document was created.\n  pub created_at: DateTime<Utc>,\n}\n\n/// Type of the document content to be presented in the search results.\n/// See: [SearchDocumentResponseItem].\n#[repr(i32)]\n#[derive(Clone, Copy, Debug, Serialize_repr, Deserialize_repr)]\npub enum SearchContentType {\n  /// Document block contents displayed as plain text.\n  PlainText = 0,\n}\n\nimpl SearchContentType {\n  /// Converts the database content type (an integer) into current [SearchContentType].\n  pub fn from_record(content_type: i32) -> Option<Self> {\n    match content_type {\n      0 => Some(SearchContentType::PlainText),\n      _ => None,\n    }\n  }\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/server_info_dto.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]\npub enum SupportedClientFeatures {\n  // Supports Collab Params serialization using Protobuf\n  CollabParamsProtobuf,\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct ServerInfoResponseItem {\n  pub supported_client_features: Vec<SupportedClientFeatures>,\n  pub minimum_supported_client_version: Option<String>,\n  pub appflowy_web_url: String,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/dto/workspace_dto.rs",
    "content": "use app_error::AppError;\nuse chrono::{DateTime, Utc};\nuse collab_entity::{CollabType, EncodedCollab};\nuse database_entity::dto::{AFRole, AFWebUser, AFWorkspaceInvitationStatus, PublishInfo};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse std::{collections::HashMap, ops::Deref};\nuse uuid::Uuid;\n\n#[derive(Deserialize, Serialize)]\npub struct WorkspaceMembers(pub Vec<WorkspaceMember>);\n#[derive(Deserialize, Serialize)]\npub struct WorkspaceMember(pub String);\nimpl Deref for WorkspaceMember {\n  type Target = String;\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl From<Vec<String>> for WorkspaceMembers {\n  fn from(value: Vec<String>) -> Self {\n    Self(value.into_iter().map(WorkspaceMember).collect())\n  }\n}\n\n#[derive(Deserialize, Serialize)]\npub struct CreateWorkspaceMembers(pub Vec<CreateWorkspaceMember>);\nimpl From<Vec<CreateWorkspaceMember>> for CreateWorkspaceMembers {\n  fn from(value: Vec<CreateWorkspaceMember>) -> Self {\n    Self(value)\n  }\n}\n\n// Deprecated\n#[derive(Deserialize, Serialize)]\npub struct CreateWorkspaceMember {\n  pub email: String,\n  pub role: AFRole,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct WorkspaceMemberInvitation {\n  pub email: String,\n  pub role: AFRole,\n\n  #[serde(default)]\n  pub skip_email_send: bool,\n  #[serde(default)]\n  pub wait_email_send: bool,\n}\n\nimpl Default for WorkspaceMemberInvitation {\n  fn default() -> Self {\n    Self {\n      email: \"\".to_string(),\n      role: AFRole::Member,\n      skip_email_send: false,\n      wait_email_send: false,\n    }\n  }\n}\n\n#[derive(Deserialize)]\npub struct WorkspaceInviteQuery {\n  pub status: Option<AFWorkspaceInvitationStatus>,\n}\n\n#[derive(Deserialize, Serialize)]\npub struct WorkspaceMemberChangeset {\n  pub email: String,\n  pub role: Option<AFRole>,\n  pub name: Option<String>,\n}\n\nimpl WorkspaceMemberChangeset {\n  pub fn new(email: String) -> Self {\n    Self {\n      email,\n      role: None,\n      name: None,\n    }\n  }\n  pub fn with_role<T: Into<AFRole>>(mut self, role: T) -> Self {\n    self.role = Some(role.into());\n    self\n  }\n  pub fn with_name(mut self, name: String) -> Self {\n    self.name = Some(name);\n    self\n  }\n}\n\n#[derive(Deserialize, Serialize)]\npub struct WorkspaceSpaceUsage {\n  pub consumed_capacity: u64,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct RepeatedBlobMetaData(pub Vec<BlobMetadata>);\n\n#[derive(Serialize, Deserialize)]\npub struct BlobMetadata {\n  pub workspace_id: Uuid,\n  pub file_id: String,\n  pub file_type: String,\n  pub file_size: i64,\n  pub modified_at: DateTime<Utc>,\n}\n\n#[derive(Serialize, Deserialize)]\npub struct CreateWorkspaceParam {\n  pub workspace_name: Option<String>,\n  #[serde(default)]\n  pub workspace_icon: Option<String>,\n}\n\n#[derive(Serialize, Deserialize, Default)]\npub struct PatchWorkspaceParam {\n  pub workspace_id: Uuid,\n  pub workspace_name: Option<String>,\n  pub workspace_icon: Option<String>,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct CollabTypeParam {\n  pub collab_type: CollabType,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct RepeatedEmbeddedCollabQuery(pub Vec<EmbeddedCollabQuery>);\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct EmbeddedCollabQuery {\n  pub collab_type: CollabType,\n  pub object_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct CollabJsonResponse {\n  pub collab: serde_json::Value,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CollabResponse {\n  #[serde(flatten)]\n  pub encode_collab: EncodedCollab,\n  /// Object ID is marked with `serde(default)` to handle cases where `object_id` is missing in the data.\n  /// This scenario can occur if the server data does not include `object_id` due to version downgrades (pre-0325 versions).\n  /// The default ensures graceful handling of missing `object_id` during deserialization, preventing errors in client applications\n  /// that expect this field to exist.\n  ///\n  /// We can remove this 'serde(default)' after the 0325 version is stable.\n  #[serde(default)]\n  pub object_id: Uuid,\n}\n\n/// Create a view in the folder, without an associated collab\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreateFolderViewParams {\n  pub parent_view_id: Uuid,\n  pub layout: ViewLayout,\n  pub name: Option<String>,\n  pub view_id: Option<Uuid>,\n  // If database id is provided, then the view will be added to the workspace database collab\n  pub database_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Space {\n  pub view_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct Page {\n  pub view_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreateSpaceParams {\n  pub space_permission: SpacePermission,\n  pub name: String,\n  pub space_icon: String,\n  pub space_icon_color: String,\n  pub view_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdateSpaceParams {\n  pub space_permission: SpacePermission,\n  pub name: String,\n  pub space_icon: String,\n  pub space_icon_color: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreatePageParams {\n  pub parent_view_id: Uuid,\n  pub layout: ViewLayout,\n  pub name: Option<String>,\n  pub page_data: Option<serde_json::Value>,\n  pub view_id: Option<Uuid>,\n  pub collab_id: Option<Uuid>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreateOrphanedViewParams {\n  pub document_id: Uuid,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePageParams {\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub is_locked: Option<bool>,\n  pub extra: Option<Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePageNameParams {\n  pub name: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePageIconParams {\n  pub icon: ViewIcon,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UpdatePageExtraParams {\n  pub extra: String,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct FavoritePageParams {\n  pub is_favorite: bool,\n  pub is_pinned: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AppendBlockToPageParams {\n  pub blocks: Vec<serde_json::Value>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct MovePageParams {\n  pub new_parent_view_id: String,\n  pub prev_view_id: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ReorderFavoritePageParams {\n  pub prev_view_id: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct AddRecentPagesParams {\n  pub recent_view_ids: Vec<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct DuplicatePageParams {\n  pub suffix: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct CreatePageDatabaseViewParams {\n  pub layout: ViewLayout,\n  pub name: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PageCollabData {\n  pub encoded_collab: Vec<u8>,\n  pub row_data: HashMap<Uuid, Vec<u8>>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PageCollab {\n  pub view: FolderView,\n  pub data: PageCollabData,\n  pub owner: Option<AFWebUser>,\n  pub last_editor: Option<AFWebUser>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PublishedDuplicate {\n  pub published_view_id: Uuid,\n  pub dest_view_id: Uuid,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct RecentFolderView {\n  #[serde(flatten)]\n  pub view: FolderView,\n  pub last_viewed_at: DateTime<Utc>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct FavoriteFolderView {\n  #[serde(flatten)]\n  pub view: FolderView,\n  pub favorited_at: DateTime<Utc>,\n  pub is_pinned: bool,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct TrashFolderView {\n  #[serde(flatten)]\n  pub view: FolderView,\n  pub deleted_at: DateTime<Utc>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct RecentSectionItems {\n  pub views: Vec<RecentFolderView>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct FavoriteSectionItems {\n  pub views: Vec<FavoriteFolderView>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct TrashSectionItems {\n  pub views: Vec<TrashFolderView>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct FolderView {\n  pub view_id: Uuid,\n  pub parent_view_id: Option<Uuid>,\n  pub prev_view_id: Option<Uuid>,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub is_space: bool,\n  pub is_private: bool,\n  pub is_published: bool,\n  pub is_favorite: bool,\n  pub layout: ViewLayout,\n  pub created_at: DateTime<Utc>,\n  pub created_by: Option<i64>,\n  pub last_edited_by: Option<i64>,\n  pub last_edited_time: DateTime<Utc>,\n  pub is_locked: Option<bool>,\n  /// contains fields like `is_space`, and font information\n  pub extra: Option<serde_json::Value>,\n  pub children: Vec<FolderView>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct FolderViewMinimal {\n  pub view_id: String,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub layout: ViewLayout,\n}\n\n/// Publish info with actual view info\n#[derive(Debug, Serialize, Deserialize)]\npub struct PublishInfoView {\n  pub view: FolderViewMinimal,\n  pub info: PublishInfo,\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct PublishPageParams {\n  pub publish_name: Option<String>,\n  pub visible_database_view_ids: Option<Vec<Uuid>>,\n  pub comments_enabled: Option<bool>,\n  pub duplicate_enabled: Option<bool>,\n}\n\n#[derive(Eq, PartialEq, Debug, Hash, Clone, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum IconType {\n  Emoji = 0,\n  Url = 1,\n  Icon = 2,\n}\n\nimpl From<u8> for IconType {\n  fn from(value: u8) -> Self {\n    match value {\n      0 => IconType::Emoji,\n      1 => IconType::Url,\n      2 => IconType::Icon,\n      _ => IconType::Emoji,\n    }\n  }\n}\n\n#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]\npub struct ViewIcon {\n  pub ty: IconType,\n  pub value: String,\n}\n\n#[derive(Eq, PartialEq, Debug, Hash, Clone, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum ViewLayout {\n  Document = 0,\n  Grid = 1,\n  Board = 2,\n  Calendar = 3,\n  Chat = 4,\n}\n\nimpl std::fmt::Display for ViewLayout {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    let s = match self {\n      ViewLayout::Document => \"Document\",\n      ViewLayout::Grid => \"Grid\",\n      ViewLayout::Board => \"Board\",\n      ViewLayout::Calendar => \"Calendar\",\n      ViewLayout::Chat => \"Chat\",\n    };\n    write!(f, \"{}\", s)\n  }\n}\n\nimpl Default for ViewLayout {\n  fn default() -> Self {\n    Self::Document\n  }\n}\n\n#[derive(Eq, PartialEq, Debug, Hash, Clone, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum SpacePermission {\n  PublicToAll = 0,\n  Private = 1,\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\npub struct QueryWorkspaceParam {\n  pub include_member_count: Option<bool>,\n  pub include_role: Option<bool>,\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\npub struct ListDatabaseRowDetailParam {\n  // Comma separated database row ids\n  // e.g. \"<uuid_1>,<uuid_2>,<uuid_3>\"\n  pub ids: String,\n  // if set to true, document data will be fetched (if exist)\n  // as markdown\n  pub with_doc: Option<bool>,\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\npub struct ListDatabaseRowUpdatedParam {\n  pub after: Option<DateTime<Utc>>,\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\npub struct DatabaseRowUpdatedItem {\n  pub updated_at: DateTime<Utc>,\n  pub row_id: String,\n}\n\nimpl ListDatabaseRowDetailParam {\n  pub fn new(ids: &[&str], with_doc: bool) -> Self {\n    Self {\n      ids: ids.join(\",\"),\n      with_doc: Some(with_doc),\n    }\n  }\n  pub fn into_ids(&self) -> Result<Vec<Uuid>, AppError> {\n    let mut res = Vec::new();\n    for uuid in self.ids.split(',') {\n      res.push(Uuid::parse_str(uuid)?);\n    }\n    Ok(res)\n  }\n}\n\n#[derive(Default, Debug, Deserialize, Serialize)]\npub struct QueryWorkspaceFolder {\n  pub depth: Option<u32>,\n  pub root_view_id: Option<Uuid>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct PublishedView {\n  pub view_id: String,\n  pub name: String,\n  pub icon: Option<ViewIcon>,\n  pub layout: ViewLayout,\n  pub is_published: bool,\n  #[serde(flatten)]\n  pub info: Option<PublishedViewInfo>,\n  /// contains fields like `is_space`, and font information\n  pub extra: Option<serde_json::Value>,\n  pub children: Vec<PublishedView>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct PublishedViewInfo {\n  pub publisher_email: String,\n  pub publish_name: String,\n  pub publish_timestamp: DateTime<Utc>,\n  pub comments_enabled: bool,\n  pub duplicate_enabled: bool,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct AFDatabase {\n  pub id: String,\n  pub views: Vec<FolderViewMinimal>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct AFDatabaseRow {\n  pub id: String,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct AFDatabaseRowDetail {\n  pub id: String,\n  // database field id -> cell data\n  pub cells: HashMap<String, serde_json::Value>,\n  pub has_doc: bool,\n  /// available if rows has doc and client request for it in [ListDatabaseRowDetailParam]\n  pub doc: Option<String>,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\npub struct AFDatabaseField {\n  pub id: String,\n  pub name: String,\n  pub field_type: String,\n  pub type_option: HashMap<String, serde_json::Value>,\n  pub is_primary: bool,\n}\n\n#[derive(Default, Debug, Clone, Serialize, Deserialize)]\npub struct AFInsertDatabaseField {\n  pub name: String,\n  pub field_type: i64,                             // FieldType ID\n  pub type_option_data: Option<serde_json::Value>, // TypeOptionData\n}\n\n#[derive(Clone, Serialize, Deserialize)]\npub struct AddDatatabaseRow {\n  pub cells: HashMap<String, serde_json::Value>,\n  pub document: Option<String>,\n}\n\n#[derive(Clone, Serialize, Deserialize)]\npub struct UpsertDatatabaseRow {\n  pub pre_hash: String, // input which will be hashed into database row id\n  pub cells: HashMap<String, serde_json::Value>,\n  pub document: Option<String>,\n}\n"
  },
  {
    "path": "libs/shared-entity/src/lib.rs",
    "content": "pub mod response;\n\npub mod dto;\n\nmod request;\n#[cfg(feature = \"cloud\")]\nmod response_actix;\n#[cfg(not(target_arch = \"wasm32\"))]\npub mod response_stream;\n"
  },
  {
    "path": "libs/shared-entity/src/request.rs",
    "content": "use serde::{Deserialize, Serialize};\n\n#[derive(Debug, Serialize, Deserialize)]\npub enum RequestData {\n  Json(Vec<u8>),\n  Encrypted(Vec<u8>),\n}\n"
  },
  {
    "path": "libs/shared-entity/src/response.rs",
    "content": "use serde::{Deserialize, Deserializer, Serialize};\nuse std::borrow::Cow;\n\nuse app_error::AppError;\npub use app_error::ErrorCode;\nuse serde::de::DeserializeOwned;\nuse std::fmt::{Debug, Display};\n\n#[cfg(feature = \"cloud\")]\npub use crate::response_actix::*;\n\n/// A macro to generate static AppResponse functions with predefined error codes.\nmacro_rules! static_app_response {\n  ($name:ident, $error:expr) => {\n    #[allow(non_snake_case, missing_docs)]\n    pub fn $name() -> AppResponse<T> {\n      AppResponse::new($error.code(), $error.to_string())\n    }\n  }; // ($name:ident, $code:expr, $msg:expr) => {\n     //   #[allow(non_snake_case, missing_docs)]\n     //   pub fn $name(msg: $msg) -> AppResponse<T> {\n     //     AppResponse::new($code, $code(msg.to_string()).to_string())\n     //   }\n     // };\n}\n\n/// Represents a standardized application response.\n///\n/// This structure is used to send consistent responses from the server,\n/// containing optional data, an error code, and a message.\n#[derive(Clone, Debug, Serialize, Deserialize)]\npub struct AppResponse<T> {\n  #[serde(skip_serializing_if = \"Option::is_none\")]\n  pub data: Option<T>,\n\n  #[serde(deserialize_with = \"default_error_code\")]\n  pub code: ErrorCode,\n\n  #[serde(default)]\n  pub message: Cow<'static, str>,\n}\n\nimpl<T> AppResponse<T> {\n  pub fn new<M: Into<Cow<'static, str>>>(code: ErrorCode, message: M) -> Self {\n    Self {\n      data: None,\n      code,\n      message: message.into(),\n    }\n  }\n\n  static_app_response!(Ok, AppError::Ok);\n\n  pub fn split(self) -> (Option<T>, AppResponseError) {\n    if self.is_ok() {\n      (self.data, AppResponseError::new(self.code, self.message))\n    } else {\n      (None, AppResponseError::new(self.code, self.message))\n    }\n  }\n\n  pub fn into_data(self) -> Result<T, AppResponseError> {\n    if self.is_ok() {\n      match self.data {\n        None => Err(AppResponseError::from(AppError::MissingPayload(\n          \"\".to_string(),\n        ))),\n        Some(data) => Ok(data),\n      }\n    } else {\n      Err(AppResponseError::new(self.code, self.message))\n    }\n  }\n\n  pub fn into_error(self) -> Result<(), AppResponseError> {\n    if matches!(self.code, ErrorCode::Ok) {\n      Ok(())\n    } else {\n      Err(AppResponseError::new(self.code, self.message))\n    }\n  }\n\n  pub fn with_data(mut self, data: T) -> Self {\n    self.data = Some(data);\n    self\n  }\n\n  pub fn with_message(mut self, message: impl Into<Cow<'static, str>>) -> Self {\n    self.message = message.into();\n    self\n  }\n\n  pub fn with_code(mut self, code: ErrorCode) -> Self {\n    self.code = code;\n    self\n  }\n\n  pub fn is_ok(&self) -> bool {\n    matches!(self.code, ErrorCode::Ok)\n  }\n}\n\nimpl<T> Display for AppResponse<T>\nwhere\n  T: Display,\n{\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\"{:?}:{}\", self.code, self.message))\n  }\n}\n\nimpl<T> std::error::Error for AppResponse<T> where T: Debug + Display {}\n\n/// Provides a conversion from `T1` to `AppResponse<T>`.\n///\n/// This implementation allows for automatic conversion of any type `T1` that can be\n/// transformed into an `AppError` into an `AppResponse<T>`. This is useful for\n/// seamlessly converting various error types into a standardized application response.\n///\nimpl<T, T1> From<T1> for AppResponse<T>\nwhere\n  T1: Into<AppResponseError>,\n{\n  fn from(value: T1) -> Self {\n    let err: AppResponseError = value.into();\n    AppResponse::new(err.code, err.message)\n  }\n}\n\nimpl<T> AppResponse<T>\nwhere\n  T: DeserializeOwned + 'static,\n{\n  pub async fn from_response(resp: reqwest::Response) -> Result<Self, anyhow::Error> {\n    let status_code = resp.status();\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      anyhow::bail!(\"got error code: {}, body: {}\", status_code, body)\n    }\n\n    let bytes = resp.bytes().await?;\n    let resp = serde_json::from_slice(&bytes)?;\n    Ok(resp)\n  }\n}\n\n#[derive(Clone, Debug, Serialize, Deserialize, thiserror::Error)]\npub struct AppResponseError {\n  #[serde(deserialize_with = \"default_error_code\")]\n  pub code: ErrorCode,\n  pub message: Cow<'static, str>,\n}\n\nimpl AppResponseError {\n  pub fn new(code: ErrorCode, message: impl Into<Cow<'static, str>>) -> Self {\n    Self {\n      code,\n      message: message.into(),\n    }\n  }\n\n  pub fn is_record_not_found(&self) -> bool {\n    matches!(self.code, ErrorCode::RecordNotFound)\n  }\n\n  pub fn is_user_unauthorized(&self) -> bool {\n    matches!(self.code, ErrorCode::UserUnAuthorized)\n  }\n}\n\nimpl<T> From<T> for AppResponseError\nwhere\n  AppError: std::convert::From<T>,\n{\n  fn from(value: T) -> Self {\n    let err = AppError::from(value);\n    Self {\n      code: err.code(),\n      message: Cow::Owned(err.to_string()),\n    }\n  }\n}\n\nimpl Display for AppResponseError {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    f.write_fmt(format_args!(\"code:{:?} msg: {}\", self.code, self.message))\n  }\n}\n\n#[cfg(feature = \"cloud\")]\nimpl actix_web::error::ResponseError for AppResponseError {\n  fn status_code(&self) -> actix_web::http::StatusCode {\n    actix_web::http::StatusCode::OK\n  }\n\n  fn error_response(&self) -> actix_web::HttpResponse {\n    actix_web::HttpResponse::Ok().json(self)\n  }\n}\n\n/// Uses a custom deserializer for error codes.\n/// This ensures that when a client receives an unrecognized error code, due to version mismatches,\n/// it defaults to the `Internal` error code.\nfn default_error_code<'a, D: Deserializer<'a>>(deserializer: D) -> Result<ErrorCode, D::Error> {\n  match ErrorCode::deserialize(deserializer) {\n    Ok(code) => Ok(code),\n    Err(_) => Ok(ErrorCode::Internal),\n  }\n}\n"
  },
  {
    "path": "libs/shared-entity/src/response_actix.rs",
    "content": "use serde::Serialize;\n\nuse crate::response::AppResponse;\nuse actix_web::web::Json;\nuse std::fmt::{Debug, Display};\n\npub type JsonAppResponse<T> = Json<AppResponse<T>>;\n\nimpl<T> From<AppResponse<T>> for JsonAppResponse<T> {\n  fn from(data: AppResponse<T>) -> Self {\n    actix_web::web::Json(data)\n  }\n}\n\nimpl<T> actix_web::error::ResponseError for AppResponse<T>\nwhere\n  T: Debug + Display + Clone + Serialize,\n{\n  fn status_code(&self) -> actix_web::http::StatusCode {\n    actix_web::http::StatusCode::OK\n  }\n\n  fn error_response(&self) -> actix_web::HttpResponse {\n    actix_web::HttpResponse::Ok().json(self)\n  }\n}\n"
  },
  {
    "path": "libs/shared-entity/src/response_stream.rs",
    "content": "use crate::response::{AppResponse, AppResponseError};\nuse app_error::{AppError, ErrorCode};\nuse bytes::{Buf, Bytes, BytesMut};\nuse futures::{ready, Stream, TryStreamExt};\n\nuse pin_project::pin_project;\nuse serde::de::DeserializeOwned;\nuse serde_json::de::SliceRead;\nuse serde_json::StreamDeserializer;\n\nuse crate::dto::ai_dto::StringOrMessage;\nuse futures::stream::StreamExt;\nuse infra::reqwest::{JsonStream, NewlineStream};\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\nimpl<T> AppResponse<T>\nwhere\n  T: DeserializeOwned + 'static,\n{\n  pub async fn json_response_stream(\n    resp: reqwest::Response,\n  ) -> Result<impl Stream<Item = Result<T, AppResponseError>>, AppResponseError> {\n    let status_code = resp.status();\n    if status_code.is_server_error() {\n      let body = resp.text().await?;\n      return Err(AppError::AIServiceUnavailable(body).into());\n    }\n\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      return Err(AppResponseError::new(ErrorCode::AIResponseError, body));\n    }\n\n    let stream = resp.bytes_stream().map_err(|err| {\n      AppResponseError::new(\n        ErrorCode::SerdeError,\n        format!(\"Error reading response stream: {}\", err),\n      )\n    });\n    let stream = check_first_item_response_error(stream).await?;\n    Ok(JsonStream::<T, _, AppResponseError>::new(stream))\n  }\n\n  pub async fn new_line_response_stream(\n    resp: reqwest::Response,\n  ) -> Result<impl Stream<Item = Result<String, AppResponseError>>, AppResponseError> {\n    let status_code = resp.status();\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      return Err(AppResponseError::new(ErrorCode::Internal, body));\n    }\n\n    let stream = resp.bytes_stream().map_err(AppResponseError::from);\n    let stream = check_first_item_response_error(stream).await?;\n    Ok(NewlineStream::new(stream))\n  }\n\n  pub async fn answer_response_stream(\n    resp: reqwest::Response,\n  ) -> Result<impl Stream<Item = Result<Bytes, AppResponseError>>, AppResponseError> {\n    let status_code = resp.status();\n    if !status_code.is_success() {\n      let body = resp.text().await?;\n      return Err(AppResponseError::new(ErrorCode::Internal, body));\n    }\n\n    let stream = resp.bytes_stream().map_err(AppResponseError::from);\n    let stream = check_first_item_response_error(stream).await?;\n    Ok(stream)\n  }\n}\n\n/// AnswerStream is a custom stream handler designed to process incoming byte streams.\n/// It alternates between handling text strings and JSON objects based on specific delimiters.\n///\n/// - When in `ReceivingStrings` state:\n///   - It accumulates bytes into `string_buffer`.\n///   - It splits the buffer at newline characters (`\\n`) to extract complete text strings.\n///   - If a null byte (`\\0`) delimiter is encountered, it switches to `ReceivingJson` state.\n///\n/// - When in `ReceivingJson` state:\n///   - It accumulates bytes into `json_buffer`.\n///   - It attempts to deserialize the accumulated bytes into JSON objects.\n///   - If deserialization is successful, it returns the JSON object and removes the processed bytes from the buffer.\n///\n/// This stream returns either text strings or deserialized JSON objects as `EitherStringOrChatMessage`,\n/// and handles errors appropriately during the conversion and deserialization processes.\n#[pin_project]\npub struct AnswerStream {\n  #[pin]\n  stream: Pin<Box<dyn Stream<Item = Result<Bytes, AppResponseError>> + Send>>,\n  json_buffer: BytesMut,\n  finished: bool,\n}\n\nimpl AnswerStream {\n  pub fn new<S>(stream: S) -> Self\n  where\n    S: Stream<Item = Result<Bytes, AppResponseError>> + Send + 'static,\n  {\n    AnswerStream {\n      stream: Box::pin(stream),\n      json_buffer: BytesMut::new(),\n      finished: false,\n    }\n  }\n}\n\nimpl Stream for AnswerStream {\n  type Item = Result<StringOrMessage, AppResponseError>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let mut this = self.project();\n\n    if *this.finished {\n      return Poll::Ready(None);\n    }\n\n    loop {\n      match ready!(this.stream.as_mut().poll_next(cx)) {\n        Some(Ok(bytes)) => {\n          // Each stream bytes if it comes with a newline character it will be a string. it's\n          // guaranteed by the server\n          const NEW_LINE: &[u8; 1] = b\"\\n\";\n          if bytes.ends_with(NEW_LINE) {\n            let bytes = &bytes[..bytes.len() - NEW_LINE.len()];\n            return match String::from_utf8(bytes.to_vec()) {\n              Ok(value) => Poll::Ready(Some(Ok(StringOrMessage::Left(value)))),\n              Err(err) => Poll::Ready(Some(Err(AppResponseError::from(err)))),\n            };\n          } else {\n            this.json_buffer.extend_from_slice(&bytes);\n            let slice_read = SliceRead::new(&this.json_buffer[..]);\n            let deserializer = StreamDeserializer::new(slice_read);\n            let mut iter = deserializer.into_iter();\n            if let Some(result) = iter.next() {\n              match result {\n                Ok(value) => {\n                  // Get the byte offset of the remaining unprocessed bytes\n                  let remaining = iter.byte_offset();\n\n                  // Advance the json_buffer to remove processed bytes\n                  this.json_buffer.advance(remaining);\n                  return Poll::Ready(Some(Ok(StringOrMessage::Right(value))));\n                },\n                Err(err) => {\n                  if err.is_eof() {\n                    continue;\n                  } else {\n                    return Poll::Ready(Some(Err(AppResponseError::from(err))));\n                  }\n                },\n              }\n            }\n          }\n        },\n        Some(Err(err)) => return Poll::Ready(Some(Err(err))),\n        None => {\n          return Poll::Ready(None);\n        },\n      }\n    }\n  }\n}\n\nasync fn check_first_item_response_error(\n  stream: impl Stream<Item = Result<Bytes, AppResponseError>> + Unpin,\n) -> Result<impl Stream<Item = Result<Bytes, AppResponseError>>, AppResponseError> {\n  let mut stream = stream.peekable();\n  if let Some(first_item) = Pin::new(&mut stream).peek().await {\n    let first_item = first_item.as_ref().map_err(|e| e.clone())?;\n    if let Ok(app_err) = serde_json::from_slice::<AppResponseError>(first_item) {\n      if app_err.code != ErrorCode::Ok {\n        return Err(app_err);\n      }\n    };\n  }\n  Ok(stream)\n}\n"
  },
  {
    "path": "libs/snowflake/Cargo.toml",
    "content": "[package]\nname = \"snowflake\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\n"
  },
  {
    "path": "libs/snowflake/src/lib.rs",
    "content": "use std::time::SystemTime;\n\nconst EPOCH: u64 = 1637806706000;\nconst NODE_ID_BITS: u64 = 10;\nconst SEQUENCE_BITS: u64 = 12;\nconst NODE_ID_SHIFT: u64 = SEQUENCE_BITS;\nconst TIMESTAMP_SHIFT: u64 = NODE_ID_BITS + SEQUENCE_BITS;\nconst SEQUENCE_MASK: u64 = (1 << SEQUENCE_BITS) - 1;\n\npub struct Snowflake {\n  node_id: u64,\n  sequence: u64,\n  last_timestamp: u64,\n}\n\nimpl Snowflake {\n  pub fn new(node_id: u64) -> Snowflake {\n    Snowflake {\n      node_id,\n      sequence: 0,\n      last_timestamp: 0,\n    }\n  }\n\n  pub fn next_id(&mut self) -> i64 {\n    let timestamp = self.timestamp();\n    if timestamp < self.last_timestamp {\n      panic!(\"Clock moved backwards!\");\n    }\n\n    if timestamp == self.last_timestamp {\n      self.sequence = (self.sequence + 1) & SEQUENCE_MASK;\n      if self.sequence == 0 {\n        self.wait_next_millis();\n      }\n    } else {\n      self.sequence = 0;\n    }\n\n    self.last_timestamp = timestamp;\n    let id = (timestamp - EPOCH) << TIMESTAMP_SHIFT | self.node_id << NODE_ID_SHIFT | self.sequence;\n    id as i64\n  }\n\n  fn wait_next_millis(&self) {\n    let mut timestamp = self.timestamp();\n    while timestamp == self.last_timestamp {\n      timestamp = self.timestamp();\n    }\n  }\n\n  fn timestamp(&self) -> u64 {\n    SystemTime::now()\n      .duration_since(SystemTime::UNIX_EPOCH)\n      .expect(\"Clock moved backwards!\")\n      .as_millis() as u64\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::Snowflake;\n\n  #[test]\n  fn gen_id() {\n    let mut snow_flake = Snowflake::new(1);\n    let id_1 = snow_flake.next_id();\n    let id_2 = snow_flake.next_id();\n\n    assert_ne!(id_1, id_2);\n  }\n}\n"
  },
  {
    "path": "libs/tonic-proto/Cargo.toml",
    "content": "[package]\nname = \"tonic-proto\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\ntonic.workspace = true\nprost.workspace = true\n\n[build-dependencies]\ntonic-build = \"0.12.3\"\n"
  },
  {
    "path": "libs/tonic-proto/build.rs",
    "content": "use tonic_build::configure;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n  configure()\n    // .type_attribute(\n    //   \"history.RepeatedSnapshotMeta\",\n    //   \"#[derive(serde::Serialize, serde::Deserialize)]\",\n    // )\n    // .type_attribute(\n    //   \"history.SnapshotMeta\",\n    //   \"#[derive(serde::Serialize, serde::Deserialize)]\",\n    // )\n    .compile_protos(&[\"proto/history.proto\"], &[\"proto\"])?;\n  Ok(())\n}\n"
  },
  {
    "path": "libs/tonic-proto/proto/history.proto",
    "content": "syntax = \"proto3\";\n\nimport \"google/protobuf/wrappers.proto\";\n\n// For optional string support\npackage history;\n\n// SnapshotMetaPB represents metadata for a snapshot.\nmessage SnapshotMetaPb {\n    // The unique identifier for the snapshot.\n    string oid = 1;\n    // The raw binary snapshot data.\n    bytes snapshot = 2;\n    // The version of the snapshot format.\n    // If the version is 1, then use a specific method to decode the snapshot.\n    // If the version is 2, then use a different method to decode the snapshot.\n    int32 snapshot_version = 3;\n    // The creation timestamp of the snapshot.\n    int64 created_at = 4;\n}\n\nmessage SingleSnapshotInfoPb {\n    HistoryStatePb history_state = 1;\n    SnapshotMetaPb snapshot_meta = 2;\n}\n\nmessage HistoryStatePb {\n    string object_id = 1;                  // Unique identifier for the object\n    bytes doc_state = 2;                   // The document state as raw binary data\n    int32 doc_state_version = 3;           // Version of the document state format, with decoding instructions based on version\n}\n"
  },
  {
    "path": "libs/tonic-proto/src/lib.rs",
    "content": "pub mod history {\n  tonic::include_proto!(\"history\");\n}\n"
  },
  {
    "path": "libs/workspace-template/Cargo.toml",
    "content": "[package]\nname = \"workspace-template\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[lib]\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\ncollab = { workspace = true }\ncollab-folder = { workspace = true }\ncollab-document = { workspace = true }\ncollab-database = { workspace = true }\ncollab-entity = { workspace = true }\nasync-trait.workspace = true\nanyhow.workspace = true\ntokio = { workspace = true, features = [\"sync\"] }\nuuid.workspace = true\nindexmap = \"2.1.0\"\nserde_json.workspace = true\nnanoid = \"0.4.0\"\nserde = { version = \"1.0\", features = [\"derive\"] }\n\n[dev-dependencies]\ntokio = { version = \"1.0\", features = [\"full\"] }\nserde_json = \"1.0\"\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\ngetrandom = { version = \"0.2\", features = [\"js\"] }\n"
  },
  {
    "path": "libs/workspace-template/assets/default_space.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [{ \"type\": \"paragraph\", \"data\": { \"delta\": [] } }]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/desktop_guide.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 1,\n        \"delta\": [{ \"insert\": \"AppFlowy Desktop on macOS, Windows, and Linux\" }]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Download\",\n            \"attributes\": { \"href\": \"https://appflowy.io/download\" }\n          },\n          { \"insert\": \" \" }\n        ]\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 3, \"delta\": [{ \"insert\": \"Basics\" }] }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [{ \"insert\": \"Click anywhere and just start typing.\" }]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Highlight\",\n            \"attributes\": { \"bg_color\": \"0x4dffeb3b\" }\n          },\n          { \"insert\": \" any text, and use the editing menu to \" },\n          { \"insert\": \"style\", \"attributes\": { \"italic\": true } },\n          { \"insert\": \" \" },\n          { \"insert\": \"your\", \"attributes\": { \"bold\": true } },\n          { \"insert\": \" \" },\n          { \"insert\": \"writing\", \"attributes\": { \"underline\": true } },\n          { \"insert\": \" \" },\n          { \"insert\": \"however\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" you \" },\n          { \"insert\": \"like.\", \"attributes\": { \"strikethrough\": true } }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"children\": [\n        {\n          \"type\": \"todo_list\",\n          \"data\": {\n            \"checked\": false,\n            \"delta\": [\n              { \"insert\": \"Type \" },\n              { \"insert\": \"/\", \"attributes\": { \"code\": true } },\n              { \"insert\": \" followed by \" },\n              { \"insert\": \"/bullet\", \"attributes\": { \"code\": true } },\n              { \"insert\": \" or \" },\n              { \"insert\": \"/num\", \"attributes\": { \"code\": true } },\n              {\n                \"insert\": \" to create a list. \",\n                \"attributes\": { \"code\": false }\n              }\n            ]\n          }\n        }\n      ],\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"As soon as you type \" },\n          {\n            \"insert\": \"/\",\n            \"attributes\": { \"code\": true, \"font_color\": \"0xff00b5ff\" }\n          },\n          { \"insert\": \" a menu will pop up. Select \" },\n          {\n            \"insert\": \"different types\",\n            \"attributes\": { \"bg_color\": \"0x4d9c27b0\" }\n          },\n          { \"insert\": \" of content blocks you can add.\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": true,\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"insert\": \"+ New Page \", \"attributes\": { \"code\": true } },\n          { \"insert\": \"button at the top of your sidebar to\" },\n          {\n            \"insert\": \" quickly\",\n            \"attributes\": { \"font_color\": \"0xff9c27b0\" }\n          },\n          { \"insert\": \" add a new\" },\n          { \"insert\": \" page\", \"attributes\": { \"font_color\": \"0xff795548\" } },\n          { \"insert\": \".\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"children\": [\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": { \"delta\": [{ \"insert\": \"Document\" }] }\n        },\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": { \"delta\": [{ \"insert\": \"Grid\" }] }\n        },\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": { \"delta\": [{ \"insert\": \"Kanban Board\" }] }\n        },\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": { \"delta\": [{ \"insert\": \"Calendar\" }] }\n        },\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": { \"delta\": [{ \"insert\": \"AI Chat\" }] }\n        },\n        {\n          \"type\": \"bulleted_list\",\n          \"data\": {\n            \"delta\": [\n              {\n                \"insert\": \"or through import\",\n                \"attributes\": { \"code\": false }\n              }\n            ]\n          }\n        }\n      ],\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"insert\": \"+\", \"attributes\": { \"code\": true } },\n          {\n            \"insert\": \" next to any page title or space name in the sidebar to add a new page/subpage:\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"divider\" },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 3,\n        \"delta\": [{ \"insert\": \"Keyboard shortcuts, markdown, and code block\" }]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Keyboard shortcuts \" },\n          {\n            \"insert\": \"guide\",\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Markdown \" },\n          {\n            \"insert\": \"reference\",\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/markdown\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Type \" },\n          { \"insert\": \"/code\", \"attributes\": { \"code\": true } },\n          {\n            \"insert\": \" to insert a code block\",\n            \"attributes\": { \"code\": false }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"code\",\n      \"data\": {\n        \"language\": \"rust\",\n        \"delta\": [\n          {\n            \"insert\": \"// This is the main function.\\nfn main() {\\n    // Print text to the console.\\n    println!(\\\"Hello World!\\\");\\n}\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"divider\" },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"align\": \"center\",\n        \"level\": 3,\n        \"delta\": [{ \"insert\": \"Spaces\" }]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"align\": \"center\",\n        \"delta\": [\n          { \"insert\": \"Create multiple spaces to better organize your work\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"multi_image\",\n      \"data\": {\n        \"layout\": 0,\n        \"images\": [\n          {\n            \"type\": 1,\n            \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/desktop_guide_1.jpg?raw=true\"\n          },\n          {\n            \"type\": 1,\n            \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/desktop_guide_2.jpg?raw=true\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"divider\" },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 2, \"delta\": [{ \"insert\": \"Have a question❓\" }] }\n    },\n    {\n      \"type\": \"quote\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"insert\": \"?\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" at the bottom right for help and support.\" }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } }\n  ]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/getting_started.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 2, \"delta\": [{ \"insert\": \"Welcome to AppFlowy\" }] }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 4,\n        \"delta\": [\n          {\n            \"insert\": \"$\",\n            \"attributes\": {\n              \"mention\": {\n                \"page_id\": \"<desktop_guide_id>\",\n                \"type\": \"page\"\n              },\n              \"bold\": true\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"toggle_list\",\n      \"children\": [\n        {\n          \"type\": \"paragraph\",\n          \"data\": {\n            \"delta\": [\n              { \"insert\": \" \" },\n              {\n                \"insert\": \"link\",\n                \"attributes\": { \"href\": \"https://appflowy.io/download\" }\n              }\n            ]\n          }\n        }\n      ],\n      \"data\": {\n        \"collapsed\": true,\n        \"delta\": [{ \"insert\": \"Download for macOS, Windows, and Linux\" }]\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 4,\n        \"delta\": [\n          {\n            \"insert\": \"$\",\n            \"attributes\": {\n              \"mention\": {\n                \"type\": \"page\",\n                \"page_id\": \"<mobile_guide_id>\"\n              },\n              \"bold\": true\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 4,\n        \"delta\": [\n          {\n            \"insert\": \"$\",\n            \"attributes\": {\n              \"mention\": {\n                \"type\": \"page\",\n                \"page_id\": \"<web_guide_id>\"\n              },\n              \"bold\": true\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 4,\n        \"delta\": [\n          {\n            \"insert\": \"$\",\n            \"attributes\": {\n              \"bold\": true,\n              \"mention\": {\n                \"page_id\": \"<todos_id>\",\n                \"type\": \"page\"\n              }\n            }\n          },\n          { \"insert\": \"quick start\", \"attributes\": { \"code\": true } }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"callout\",\n      \"data\": {\n        \"bgColor\": \"0x0\",\n        \"icon\": \"\",\n        \"delta\": [\n          { \"insert\": \"Ask AI\", \"attributes\": { \"bold\": true } },\n          {\n            \"insert\": \" powered by advanced AI models: chat, search, write, and much more ✨\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"divider\" },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"❤️Love AppFlowy and open source? Follow our latest product updates:\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Twitter\",\n            \"attributes\": { \"href\": \"https://twitter.com/appflowy\" }\n          },\n          { \"insert\": \": @appflowy\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Reddit\",\n            \"attributes\": { \"href\": \"https://www.reddit.com/r/AppFlowy/\" }\n          },\n          { \"insert\": \": r/appflowy\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Github\",\n            \"attributes\": {\n              \"href\": \"https://github.com/AppFlowy-IO/AppFlowy\"\n            }\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } }\n  ]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/inbox.json",
    "content": "{\n  \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n  \"inline_view_id\": \"a8739c21-ed9e-4e05-ad6c-29c467b4129e\",\n  \"views\": [\n    {\n      \"id\": \"efc0db35-29db-4dc4-b48a-da46282809e5\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"name\": \"Grid\",\n      \"layout\": 0,\n      \"layout_settings\": {},\n      \"filters\": [],\n      \"group_settings\": [],\n      \"sorts\": [],\n      \"row_orders\": [\n        { \"id\": \"ea5da1fc-cb8f-4fd0-bef1-fb5627a9cc71\", \"height\": 60 },\n        { \"id\": \"3e7fdb49-6ed2-4e99-b4dd-b90c8290771f\", \"height\": 60 },\n        { \"id\": \"380ee34e-8be7-43ff-ac37-27a2f705c6e9\", \"height\": 60 },\n        { \"id\": \"fffc8261-d736-4408-acaa-ead7164b5bc0\", \"height\": 60 },\n        { \"id\": \"207f0ae1-61ee-4b3c-9745-dcbb1530a619\", \"height\": 60 }\n      ],\n      \"field_orders\": [\n        { \"id\": \"phVRgL\" },\n        { \"id\": \"SqwRg1\" },\n        { \"id\": \"wdX8DG\" },\n        { \"id\": \"KinVda\" },\n        { \"id\": \"3AE6iK\" }\n      ],\n      \"field_settings\": {\n        \"wdX8DG\": { \"wrap\": true, \"visibility\": 0, \"width\": 218 },\n        \"KinVda\": { \"visibility\": 0 },\n        \"3AE6iK\": { \"visibility\": 0 }\n      },\n      \"created_at\": 0,\n      \"modified_at\": 1723799193\n    },\n    {\n      \"id\": \"a8739c21-ed9e-4e05-ad6c-29c467b4129e\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"name\": \"Untitled\",\n      \"layout\": 0,\n      \"layout_settings\": {\n        \"1\": { \"collapse_hidden_groups\": true, \"hide_ungrouped_column\": true }\n      },\n      \"filters\": [],\n      \"group_settings\": [\n        {\n          \"groups\": [\n            { \"id\": \"SqwRg1\", \"visible\": true },\n            { \"visible\": true, \"id\": \"CEZD\" },\n            { \"id\": \"TznH\", \"visible\": true },\n            { \"visible\": true, \"id\": \"__n6\" }\n          ],\n          \"ty\": 3,\n          \"id\": \"g:gJ9y65\",\n          \"content\": \"\",\n          \"field_id\": \"SqwRg1\"\n        }\n      ],\n      \"sorts\": [],\n      \"row_orders\": [\n        { \"id\": \"ea5da1fc-cb8f-4fd0-bef1-fb5627a9cc71\", \"height\": 60 },\n        { \"id\": \"3e7fdb49-6ed2-4e99-b4dd-b90c8290771f\", \"height\": 60 },\n        { \"id\": \"380ee34e-8be7-43ff-ac37-27a2f705c6e9\", \"height\": 60 },\n        { \"id\": \"fffc8261-d736-4408-acaa-ead7164b5bc0\", \"height\": 60 },\n        { \"id\": \"207f0ae1-61ee-4b3c-9745-dcbb1530a619\", \"height\": 60 }\n      ],\n      \"field_orders\": [\n        { \"id\": \"phVRgL\" },\n        { \"id\": \"SqwRg1\" },\n        { \"id\": \"wdX8DG\" },\n        { \"id\": \"KinVda\" },\n        { \"id\": \"3AE6iK\" }\n      ],\n      \"field_settings\": {\n        \"3AE6iK\": { \"visibility\": 2, \"width\": 150, \"wrap\": true },\n        \"SqwRg1\": { \"visibility\": 1, \"width\": 150, \"wrap\": true },\n        \"wdX8DG\": { \"width\": 150, \"wrap\": true, \"visibility\": 2 },\n        \"phVRgL\": { \"width\": 150, \"visibility\": 0, \"wrap\": true },\n        \"KinVda\": { \"visibility\": 1 }\n      },\n      \"created_at\": 1723792464,\n      \"modified_at\": 1723799193\n    }\n  ],\n  \"fields\": [\n    {\n      \"id\": \"phVRgL\",\n      \"name\": \"Description\",\n      \"field_type\": 0,\n      \"type_options\": { \"0\": { \"data\": \"\" } },\n      \"is_primary\": true\n    },\n    {\n      \"id\": \"SqwRg1\",\n      \"name\": \"Status\",\n      \"field_type\": 3,\n      \"type_options\": {\n        \"3\": {\n          \"content\": \"{\\\"options\\\":[{\\\"id\\\":\\\"CEZD\\\",\\\"name\\\":\\\"To Do\\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"TznH\\\",\\\"name\\\":\\\"Doing\\\",\\\"color\\\":\\\"Orange\\\"},{\\\"id\\\":\\\"__n6\\\",\\\"name\\\":\\\"✅ Done\\\",\\\"color\\\":\\\"Yellow\\\"}],\\\"disable_color\\\":false}\"\n        }\n      },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"wdX8DG\",\n      \"name\": \"Multiselect\",\n      \"field_type\": 4,\n      \"type_options\": {\n        \"4\": {\n          \"content\": \"{\\\"options\\\":[{\\\"id\\\":\\\"4PDn\\\",\\\"name\\\":\\\"get things done\\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"Bpyg\\\",\\\"name\\\":\\\"self-host\\\",\\\"color\\\":\\\"Blue\\\"},{\\\"id\\\":\\\"GOQj\\\",\\\"name\\\":\\\"open source\\\",\\\"color\\\":\\\"Aqua\\\"},{\\\"id\\\":\\\"BD-T\\\",\\\"name\\\":\\\"looks great\\\",\\\"color\\\":\\\"Green\\\"},{\\\"id\\\":\\\"6UxM\\\",\\\"name\\\":\\\"fast\\\",\\\"color\\\":\\\"Lime\\\"},{\\\"id\\\":\\\"g2Uq\\\",\\\"name\\\":\\\"Claude 3\\\",\\\"color\\\":\\\"Yellow\\\"},{\\\"id\\\":\\\"Tt-J\\\",\\\"name\\\":\\\"GPT-4o\\\",\\\"color\\\":\\\"Orange\\\"},{\\\"id\\\":\\\"5QDY\\\",\\\"name\\\":\\\"Q&A\\\",\\\"color\\\":\\\"LightPink\\\"},{\\\"id\\\":\\\"XYUx\\\",\\\"name\\\":\\\"news\\\",\\\"color\\\":\\\"Pink\\\"},{\\\"id\\\":\\\"hoZx\\\",\\\"name\\\":\\\"social\\\",\\\"color\\\":\\\"Purple\\\"}],\\\"disable_color\\\":false}\"\n        },\n        \"0\": {\n          \"data\": \"\",\n          \"content\": \"{\\\"options\\\":[],\\\"disable_color\\\":false}\"\n        }\n      },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"KinVda\",\n      \"name\": \"Tasks\",\n      \"field_type\": 7,\n      \"type_options\": { \"7\": {}, \"0\": { \"data\": \"\" } },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"3AE6iK\",\n      \"name\": \"Last modified\",\n      \"field_type\": 8,\n      \"type_options\": {\n        \"0\": {\n          \"time_format\": 1,\n          \"include_time\": true,\n          \"field_type\": 8,\n          \"data\": \"\",\n          \"date_format\": 3\n        },\n        \"8\": {\n          \"field_type\": 8,\n          \"date_format\": 3,\n          \"include_time\": true,\n          \"time_format\": 1\n        }\n      },\n      \"is_primary\": false\n    }\n  ],\n  \"rows\": [\n    {\n      \"id\": \"ea5da1fc-cb8f-4fd0-bef1-fb5627a9cc71\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"cells\": {\n        \"phVRgL\": {\n          \"field_type\": 0,\n          \"last_modified\": 1723792576,\n          \"created_at\": 1723792501,\n          \"data\": \"Follow us on Twitter @appflowy\"\n        },\n        \"SqwRg1\": { \"field_type\": 3, \"data\": \"CEZD\" },\n        \"wdX8DG\": {\n          \"field_type\": 4,\n          \"last_modified\": 1723792957,\n          \"data\": \"hoZx,XYUx\",\n          \"created_at\": 1723792951\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1723792464,\n      \"modified_at\": 1723792957\n    },\n    {\n      \"id\": \"3e7fdb49-6ed2-4e99-b4dd-b90c8290771f\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"cells\": {\n        \"phVRgL\": {\n          \"field_type\": 0,\n          \"data\": \"Try out AI Chat 💬\",\n          \"last_modified\": 1723792781,\n          \"created_at\": 1723792584\n        },\n        \"KinVda\": {\n          \"field_type\": 7,\n          \"data\": \"{\\\"options\\\":[{\\\"id\\\":\\\"HJlI\\\",\\\"name\\\":\\\"Create an AI chat \\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"4Ik2\\\",\\\"name\\\":\\\"Ask a question about your pages\\\",\\\"color\\\":\\\"Purple\\\"}],\\\"selected_option_ids\\\":[\\\"HJlI\\\"]}\",\n          \"created_at\": 1723793102,\n          \"last_modified\": 1723793133\n        },\n        \"wdX8DG\": {\n          \"field_type\": 4,\n          \"created_at\": 1723792994,\n          \"data\": \"5QDY,Tt-J,g2Uq\",\n          \"last_modified\": 1723793006\n        },\n        \"SqwRg1\": { \"field_type\": 3, \"data\": \"CEZD\" },\n        \"1li9Eo\": {\n          \"last_modified\": 1723793216,\n          \"field_type\": 12,\n          \"data\": \"Essayez le Chatbot Intelligence Artificielle 💬,À faire,Q&A,GPT-4o,Claude 3,Créer un chatbot ,Demander une question sur vos pages\",\n          \"created_at\": 1723793216\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1723792464,\n      \"modified_at\": 1723793216\n    },\n    {\n      \"id\": \"380ee34e-8be7-43ff-ac37-27a2f705c6e9\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"data\": \"BD-T,6UxM\",\n          \"created_at\": 1723793015,\n          \"field_type\": 4,\n          \"last_modified\": 1723793037\n        },\n        \"SqwRg1\": { \"data\": \"CEZD\", \"field_type\": 3 },\n        \"phVRgL\": {\n          \"field_type\": 0,\n          \"created_at\": 1723792611,\n          \"last_modified\": 1723792623,\n          \"data\": \"Install AppFlowy Mobile\"\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1723792464,\n      \"modified_at\": 1723793037\n    },\n    {\n      \"id\": \"fffc8261-d736-4408-acaa-ead7164b5bc0\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"cells\": {\n        \"phVRgL\": {\n          \"data\": \"Love AppFlowy and open source\",\n          \"last_modified\": 1723792862,\n          \"field_type\": 0,\n          \"created_at\": 1723792815\n        },\n        \"wdX8DG\": {\n          \"created_at\": 1723793051,\n          \"field_type\": 4,\n          \"last_modified\": 1723793058,\n          \"data\": \"GOQj,Bpyg\"\n        },\n        \"SqwRg1\": { \"data\": \"TznH\", \"field_type\": 3 }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1723792698,\n      \"modified_at\": 1723793058\n    },\n    {\n      \"id\": \"207f0ae1-61ee-4b3c-9745-dcbb1530a619\",\n      \"database_id\": \"4e1b59a3-0767-43bc-86f2-8078dd6c0c49\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"field_type\": 4,\n          \"last_modified\": 1723793067,\n          \"data\": \"4PDn\",\n          \"created_at\": 1723793067\n        },\n        \"SqwRg1\": {\n          \"last_modified\": 1723792881,\n          \"data\": \"__n6\",\n          \"created_at\": 1723792881,\n          \"field_type\": 3\n        },\n        \"phVRgL\": {\n          \"field_type\": 0,\n          \"data\": \"Create an AppFlowy Cloud account\"\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1723792765,\n      \"modified_at\": 1723793067\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/initial_document.json",
    "content": "{\n  \"type\": \"page\",\n  \"data\": {},\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Welcome to AppFlowy!\"\n          }\n        ],\n        \"level\": 1\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Here are the basics\"\n          }\n        ],\n        \"level\": 2\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Here is H3\"\n          }\n        ],\n        \"level\": 3\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click anywhere and just start typing.\"\n          }\n        ],\n        \"checked\": false\n      },\n      \"children\": [\n        {\n          \"type\": \"todo_list\",\n          \"data\": {\n            \"delta\": [\n              {\n                \"insert\": \"Click \"\n              },\n              {\n                \"attributes\": {\n                  \"code\": true\n                },\n                \"insert\": \"Enter\"\n              },\n              {\n                \"insert\": \" to create a new line.\"\n              }\n            ],\n            \"checked\": false\n          }\n        }\n      ]\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"attributes\": {\n              \"bg_color\": \"0x4dffeb3b\"\n            },\n            \"insert\": \"Highlight \"\n          },\n          {\n            \"insert\": \"any text, and use the editing menu to \"\n          },\n          {\n            \"attributes\": {\n              \"italic\": true\n            },\n            \"insert\": \"style\"\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"attributes\": {\n              \"bold\": true\n            },\n            \"insert\": \"your\"\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"attributes\": {\n              \"underline\": true\n            },\n            \"insert\": \"writing\"\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"however\"\n          },\n          {\n            \"insert\": \" you \"\n          },\n          {\n            \"attributes\": {\n              \"strikethrough\": true\n            },\n            \"insert\": \"like.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"As soon as you type \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true,\n              \"font_color\": \"0xff00b5ff\"\n            },\n            \"insert\": \"/\"\n          },\n          {\n            \"insert\": \" a menu will pop up. Select \"\n          },\n          {\n            \"attributes\": {\n              \"bg_color\": \"0x4d9c27b0\"\n            },\n            \"insert\": \"different types\"\n          },\n          {\n            \"insert\": \" of content blocks you can add.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Type \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"/\"\n          },\n          {\n            \"insert\": \" followed by \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"/bullet\"\n          },\n          {\n            \"insert\": \" or \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"/num\"\n          },\n          {\n            \"attributes\": {\n              \"code\": false\n            },\n            \"insert\": \" to create a list.\"\n          }\n        ],\n        \"checked\": false\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"+ New Page \"\n          },\n          {\n            \"insert\": \"button at the bottom of your sidebar to add a new page.\"\n          }\n        ],\n        \"checked\": true\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"+\"\n          },\n          {\n            \"insert\": \" next to any page title in the sidebar to \"\n          },\n          {\n            \"attributes\": {\n              \"font_color\": \"0xff8427e0\"\n            },\n            \"insert\": \"quickly\"\n          },\n          {\n            \"insert\": \" add a new subpage, \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"Document\"\n          },\n          {\n            \"attributes\": {\n              \"code\": false\n            },\n            \"insert\": \", \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"Grid\"\n          },\n          {\n            \"attributes\": {\n              \"code\": false\n            },\n            \"insert\": \", or \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"Kanban Board\"\n          },\n          {\n            \"attributes\": {\n              \"code\": false\n            },\n            \"insert\": \".\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"divider\"\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Keyboard shortcuts, markdown, and code block\"\n          }\n        ],\n        \"level\": 2\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Keyboard shortcuts \"\n          },\n          {\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts\"\n            },\n            \"insert\": \"guide\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Markdown \"\n          },\n          {\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/markdown\"\n            },\n            \"insert\": \"reference\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Type \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"/code\"\n          },\n          {\n            \"attributes\": {\n              \"code\": false\n            },\n            \"insert\": \" to insert a code block\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"code\",\n      \"data\": {\n        \"language\": \"rust\",\n        \"delta\": [\n          {\n            \"insert\": \"// This is the main function.\\nfn main() {\\n    // Print text to the console.\\n    println!(\\\"Hello World!\\\");\\n}\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      },\n      \"children\": [\n        {\n          \"type\": \"paragraph\",\n          \"data\": {\n            \"delta\": [\n              {\n                \"insert\": \"This is a paragraph\"\n              }\n            ]\n          },\n          \"children\": [\n            {\n              \"type\": \"paragraph\",\n              \"data\": {\n                \"delta\": [\n                  {\n                    \"insert\": \"This is a paragraph\"\n                  }\n                ]\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 2,\n        \"delta\": [\n          {\n            \"insert\": \"Have a question❓\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"toggle_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"?\"\n          },\n          {\n            \"insert\": \" at the bottom right for help and support.\"\n          }\n        ]\n      },\n      \"children\": [\n        {\n          \"type\": \"paragraph\",\n          \"data\": {\n            \"delta\": [\n              {\n                \"insert\": \"This is a paragraph\"\n              }\n            ]\n          }\n        },\n        {\n          \"type\": \"paragraph\",\n          \"data\": {\n            \"delta\": [\n              {\n                \"insert\": \"This is a paragraph\"\n              }\n            ]\n          }\n        }\n      ]\n    },\n    {\n      \"type\": \"quote\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"attributes\": {\n              \"code\": true\n            },\n            \"insert\": \"?\"\n          },\n          {\n            \"insert\": \" at the bottom right for help and support.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"callout\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"\\nLike AppFlowy? Follow us:\\n\"\n          },\n          {\n            \"attributes\": {\n              \"href\": \"https://github.com/AppFlowy-IO/AppFlowy\"\n            },\n            \"insert\": \"GitHub\"\n          },\n          {\n            \"insert\": \"\\n\"\n          },\n          {\n            \"attributes\": {\n              \"href\": \"https://twitter.com/appflowy\"\n            },\n            \"insert\": \"Twitter\"\n          },\n          {\n            \"insert\": \": @appflowy\\n\"\n          },\n          {\n            \"attributes\": {\n              \"href\": \"https://blog-appflowy.ghost.io/\"\n            },\n            \"insert\": \"Newsletter\"\n          },\n          {\n            \"insert\": \"\\n\"\n          }\n        ],\n        \"icon\": \"🥰\"\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    }\n  ]\n}"
  },
  {
    "path": "libs/workspace-template/assets/mobile_guide.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 3, \"delta\": [{ \"insert\": \"Adding content\" }] }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"insert\": \"+\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" \", \"attributes\": { \"code\": false } },\n          { \"insert\": \"in the toolbar to add a block\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"Type \" },\n          { \"insert\": \"-[]\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" to insert a to-do list\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"bulleted_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"-\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" or \" },\n          { \"insert\": \"*\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" for bulleted list\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"1.\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" for numbered list\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"toggle_list\",\n      \"children\": [\n        {\n          \"type\": \"paragraph\",\n          \"data\": { \"delta\": [{ \"insert\": \"Expand or collapse\" }] }\n        }\n      ],\n      \"data\": {\n        \"collapsed\": true,\n        \"delta\": [\n          { \"insert\": \">\", \"attributes\": { \"code\": true } },\n          { \"insert\": \" for toggle list\" }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 3, \"delta\": [{ \"insert\": \"Styling\" }] }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Select text to \" },\n          { \"insert\": \"style\", \"attributes\": { \"bg_color\": \"0xff6455a2\" } },\n          { \"insert\": \" using the \" },\n          {\n            \"insert\": \"toolbar\",\n            \"attributes\": { \"italic\": true, \"bold\": true, \"underline\": true }\n          },\n          { \"insert\": \" menu\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"More styling can be found in \" },\n          { \"insert\": \"Aa\", \"attributes\": { \"code\": true } }\n        ]\n      }\n    },\n    {\n      \"type\": \"quote\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"AppFlowy allows you to style your content in a beautiful and simple way\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"width\": 369.44921875,\n        \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/mobile_guide_1.png?raw=true\",\n        \"align\": \"left\"\n      }\n    },\n    { \"type\": \"divider\" },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 3, \"delta\": [{ \"insert\": \"Database views\" }] }\n    },\n    {\n      \"type\": \"callout\",\n      \"data\": {\n        \"icon\": \"📌\",\n        \"delta\": [\n          {\n            \"insert\": \"Visualize your content in different ways—Kanban, Grid, and Calendar views\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click on a database record (row/card) to open the Card view\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"multi_image\",\n      \"data\": {\n        \"layout\": 0,\n        \"images\": [\n          {\n            \"type\": 1,\n            \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/mobile_guide_2.png?raw=true\"\n          },\n          {\n            \"type\": 1,\n            \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/mobile_guide_3.png?raw=true\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"In a Grid view, tap on a property name to edit the property.\",\n            \"attributes\": { \"bold\": false }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/mobile_guide_4.png?raw=true\",\n        \"width\": 342,\n        \"align\": \"left\"\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 3, \"delta\": [{ \"insert\": \"Manage pages\" }] }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Swipe left on a page to show the actions: add a subpage and more actions\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"align\": \"left\",\n        \"width\": 342,\n        \"url\": \"https://github.com/AppFlowy-IO/AppFlowy/blob/main/doc/readme/mobile_guide_5.png?raw=true\"\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"paragraph\",\n      \"data\": { \"delta\": [{ \"insert\": \"More actions include:\" }] }\n    },\n    {\n      \"type\": \"bulleted_list\",\n      \"data\": { \"delta\": [{ \"insert\": \"Add to Favorites \" }] }\n    },\n    {\n      \"type\": \"bulleted_list\",\n      \"data\": { \"delta\": [{ \"insert\": \"Rename\" }] }\n    },\n    {\n      \"type\": \"bulleted_list\",\n      \"data\": { \"delta\": [{ \"insert\": \"Duplicate \" }] }\n    },\n    { \"type\": \"bulleted_list\", \"data\": { \"delta\": [{ \"insert\": \"Delete\" }] } }\n  ]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/to-dos.json",
    "content": "{\n  \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n  \"inline_view_id\": \"010326bb-a6de-42c1-8037-7a0f825e029d\",\n  \"views\": [\n    {\n      \"id\": \"010326bb-a6de-42c1-8037-7a0f825e029d\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"name\": \"Untitled\",\n      \"layout\": 1,\n      \"layout_settings\": {\n        \"1\": { \"collapse_hidden_groups\": true, \"hide_ungrouped_column\": true }\n      },\n      \"filters\": [],\n      \"group_settings\": [\n        {\n          \"field_id\": \"SqwRg1\",\n          \"id\": \"g:gJ9y65\",\n          \"content\": \"\",\n          \"ty\": 3,\n          \"groups\": [\n            { \"visible\": true, \"id\": \"SqwRg1\" },\n            { \"visible\": true, \"id\": \"CEZD\" },\n            { \"visible\": true, \"id\": \"TznH\" },\n            { \"id\": \"__n6\", \"visible\": true }\n          ]\n        }\n      ],\n      \"sorts\": [],\n      \"row_orders\": [\n        { \"id\": \"0003a3cc-afd1-49ca-a75c-f360dd82ffe3\", \"height\": 60 },\n        { \"id\": \"de94c0c4-6434-46ce-b0f8-7d3c0463e698\", \"height\": 60 },\n        { \"id\": \"7ae3af92-efe0-4abb-b861-30f488d164c0\", \"height\": 60 },\n        { \"id\": \"a8ae4baf-0eed-42c5-a2b7-1683c5a79da6\", \"height\": 60 },\n        { \"id\": \"69ba4b59-d541-4131-a010-7a03f73ff088\", \"height\": 60 }\n      ],\n      \"field_orders\": [\n        { \"id\": \"phVRgL\" },\n        { \"id\": \"SqwRg1\" },\n        { \"id\": \"wdX8DG\" },\n        { \"id\": \"KinVda\" },\n        { \"id\": \"3AE6iK\" }\n      ],\n      \"field_settings\": {\n        \"SqwRg1\": { \"wrap\": true, \"width\": 150, \"visibility\": 1 },\n        \"wdX8DG\": { \"visibility\": 2, \"wrap\": true, \"width\": 150 },\n        \"phVRgL\": { \"visibility\": 0, \"wrap\": true, \"width\": 150 },\n        \"KinVda\": { \"visibility\": 1 },\n        \"3AE6iK\": { \"visibility\": 2, \"width\": 150, \"wrap\": true }\n      },\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    },\n    {\n      \"id\": \"0efd6c11-d38e-451c-9842-bc2322ad543f\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"name\": \"Grid\",\n      \"layout\": 0,\n      \"layout_settings\": {},\n      \"filters\": [],\n      \"group_settings\": [],\n      \"sorts\": [],\n      \"row_orders\": [\n        { \"id\": \"0003a3cc-afd1-49ca-a75c-f360dd82ffe3\", \"height\": 60 },\n        { \"id\": \"de94c0c4-6434-46ce-b0f8-7d3c0463e698\", \"height\": 60 },\n        { \"id\": \"7ae3af92-efe0-4abb-b861-30f488d164c0\", \"height\": 60 },\n        { \"id\": \"a8ae4baf-0eed-42c5-a2b7-1683c5a79da6\", \"height\": 60 },\n        { \"id\": \"69ba4b59-d541-4131-a010-7a03f73ff088\", \"height\": 60 }\n      ],\n      \"field_orders\": [\n        { \"id\": \"phVRgL\" },\n        { \"id\": \"SqwRg1\" },\n        { \"id\": \"wdX8DG\" },\n        { \"id\": \"KinVda\" },\n        { \"id\": \"3AE6iK\" }\n      ],\n      \"field_settings\": {\n        \"KinVda\": { \"visibility\": 0 },\n        \"wdX8DG\": { \"visibility\": 0, \"wrap\": true, \"width\": 218 },\n        \"3AE6iK\": { \"visibility\": 0 }\n      },\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    }\n  ],\n  \"fields\": [\n    {\n      \"id\": \"phVRgL\",\n      \"name\": \"Description\",\n      \"field_type\": 0,\n      \"type_options\": { \"0\": { \"data\": \"\" } },\n      \"is_primary\": true\n    },\n    {\n      \"id\": \"SqwRg1\",\n      \"name\": \"Status\",\n      \"field_type\": 3,\n      \"type_options\": {\n        \"3\": {\n          \"content\": \"{\\\"options\\\":[{\\\"id\\\":\\\"CEZD\\\",\\\"name\\\":\\\"To Do\\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"TznH\\\",\\\"name\\\":\\\"Doing\\\",\\\"color\\\":\\\"Orange\\\"},{\\\"id\\\":\\\"__n6\\\",\\\"name\\\":\\\"✅ Done\\\",\\\"color\\\":\\\"Yellow\\\"}],\\\"disable_color\\\":false}\"\n        }\n      },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"wdX8DG\",\n      \"name\": \"Multiselect\",\n      \"field_type\": 4,\n      \"type_options\": {\n        \"0\": {\n          \"content\": \"{\\\"options\\\":[],\\\"disable_color\\\":false}\",\n          \"data\": \"\"\n        },\n        \"4\": {\n          \"content\": \"{\\\"options\\\":[{\\\"id\\\":\\\"4PDn\\\",\\\"name\\\":\\\"get things done\\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"Bpyg\\\",\\\"name\\\":\\\"self-host\\\",\\\"color\\\":\\\"Blue\\\"},{\\\"id\\\":\\\"GOQj\\\",\\\"name\\\":\\\"open source\\\",\\\"color\\\":\\\"Aqua\\\"},{\\\"id\\\":\\\"BD-T\\\",\\\"name\\\":\\\"looks great\\\",\\\"color\\\":\\\"Green\\\"},{\\\"id\\\":\\\"6UxM\\\",\\\"name\\\":\\\"fast\\\",\\\"color\\\":\\\"Lime\\\"},{\\\"id\\\":\\\"g2Uq\\\",\\\"name\\\":\\\"Claude 3\\\",\\\"color\\\":\\\"Yellow\\\"},{\\\"id\\\":\\\"Tt-J\\\",\\\"name\\\":\\\"GPT-4o\\\",\\\"color\\\":\\\"Orange\\\"},{\\\"id\\\":\\\"5QDY\\\",\\\"name\\\":\\\"Q&A\\\",\\\"color\\\":\\\"LightPink\\\"},{\\\"id\\\":\\\"XYUx\\\",\\\"name\\\":\\\"news\\\",\\\"color\\\":\\\"Pink\\\"},{\\\"id\\\":\\\"hoZx\\\",\\\"name\\\":\\\"social\\\",\\\"color\\\":\\\"Purple\\\"}],\\\"disable_color\\\":false}\"\n        }\n      },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"KinVda\",\n      \"name\": \"Tasks\",\n      \"field_type\": 7,\n      \"type_options\": { \"0\": { \"data\": \"\" }, \"7\": {} },\n      \"is_primary\": false\n    },\n    {\n      \"id\": \"3AE6iK\",\n      \"name\": \"Last modified\",\n      \"field_type\": 8,\n      \"type_options\": {\n        \"8\": {\n          \"time_format\": 1,\n          \"include_time\": true,\n          \"date_format\": 3,\n          \"field_type\": 8\n        },\n        \"0\": {\n          \"date_format\": 3,\n          \"include_time\": true,\n          \"field_type\": 8,\n          \"time_format\": 1,\n          \"data\": \"\"\n        }\n      },\n      \"is_primary\": false\n    }\n  ],\n  \"rows\": [\n    {\n      \"id\": \"0003a3cc-afd1-49ca-a75c-f360dd82ffe3\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"created_at\": 1723792951,\n          \"field_type\": 4,\n          \"last_modified\": 1723792957,\n          \"data\": \"hoZx,XYUx\"\n        },\n        \"SqwRg1\": { \"field_type\": 3, \"data\": \"CEZD\" },\n        \"phVRgL\": {\n          \"last_modified\": 1723792576,\n          \"field_type\": 0,\n          \"created_at\": 1723792501,\n          \"data\": \"Follow us on Twitter @appflowy\"\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    },\n    {\n      \"id\": \"de94c0c4-6434-46ce-b0f8-7d3c0463e698\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"data\": \"5QDY,Tt-J,g2Uq\",\n          \"last_modified\": 1723793006,\n          \"field_type\": 4,\n          \"created_at\": 1723792994\n        },\n        \"1li9Eo\": {\n          \"data\": \"Essayez le Chatbot Intelligence Artificielle 💬,À faire,Q&A,GPT-4o,Claude 3,Créer un chatbot ,Demander une question sur vos pages\",\n          \"created_at\": 1723793216,\n          \"field_type\": 12,\n          \"last_modified\": 1723793216\n        },\n        \"KinVda\": {\n          \"data\": \"{\\\"options\\\":[{\\\"id\\\":\\\"HJlI\\\",\\\"name\\\":\\\"Create an AI chat \\\",\\\"color\\\":\\\"Purple\\\"},{\\\"id\\\":\\\"4Ik2\\\",\\\"name\\\":\\\"Ask a question about your pages\\\",\\\"color\\\":\\\"Purple\\\"}],\\\"selected_option_ids\\\":[\\\"HJlI\\\"]}\",\n          \"created_at\": 1723793102,\n          \"field_type\": 7,\n          \"last_modified\": 1723793133\n        },\n        \"phVRgL\": {\n          \"created_at\": 1723792584,\n          \"data\": \"Try out AI Chat 💬\",\n          \"field_type\": 0,\n          \"last_modified\": 1723792781\n        },\n        \"SqwRg1\": { \"data\": \"CEZD\", \"field_type\": 3 }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    },\n    {\n      \"id\": \"7ae3af92-efe0-4abb-b861-30f488d164c0\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"field_type\": 4,\n          \"created_at\": 1723793015,\n          \"last_modified\": 1723793037,\n          \"data\": \"BD-T,6UxM\"\n        },\n        \"phVRgL\": {\n          \"last_modified\": 1723792623,\n          \"created_at\": 1723792611,\n          \"field_type\": 0,\n          \"data\": \"Install AppFlowy Mobile\"\n        },\n        \"SqwRg1\": { \"field_type\": 3, \"data\": \"CEZD\" }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    },\n    {\n      \"id\": \"a8ae4baf-0eed-42c5-a2b7-1683c5a79da6\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"cells\": {\n        \"phVRgL\": {\n          \"last_modified\": 1723792862,\n          \"data\": \"Love AppFlowy and open source\",\n          \"field_type\": 0,\n          \"created_at\": 1723792815\n        },\n        \"wdX8DG\": {\n          \"data\": \"GOQj,Bpyg\",\n          \"last_modified\": 1723793058,\n          \"field_type\": 4,\n          \"created_at\": 1723793051\n        },\n        \"SqwRg1\": { \"field_type\": 3, \"data\": \"TznH\" }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    },\n    {\n      \"id\": \"69ba4b59-d541-4131-a010-7a03f73ff088\",\n      \"database_id\": \"57eb746e-9913-4bd3-a3f2-eb7aa40e21da\",\n      \"cells\": {\n        \"wdX8DG\": {\n          \"data\": \"4PDn\",\n          \"last_modified\": 1723793067,\n          \"field_type\": 4,\n          \"created_at\": 1723793067\n        },\n        \"phVRgL\": {\n          \"field_type\": 0,\n          \"data\": \"Create an AppFlowy Cloud account\"\n        },\n        \"SqwRg1\": {\n          \"created_at\": 1723792881,\n          \"field_type\": 3,\n          \"last_modified\": 1723792881,\n          \"data\": \"__n6\"\n        }\n      },\n      \"height\": 60,\n      \"visibility\": true,\n      \"created_at\": 1724830348,\n      \"modified_at\": 1724830348\n    }\n  ]\n}\n"
  },
  {
    "path": "libs/workspace-template/assets/vault_get_started.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 2,\n        \"delta\": [\n          {\n            \"insert\": \"Features\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Local AI Power\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Data Privacy\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Offline Operation\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Custom Model Integration\"\n          }\n        ]\n      }\n    }\n  ]\n}"
  },
  {
    "path": "libs/workspace-template/assets/web_guide.json",
    "content": "{\n  \"type\": \"page\",\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 1,\n        \"delta\": [\n          {\n            \"insert\": \"AppFlowy is right in \"\n          },\n          {\n            \"insert\": \"your browser\",\n            \"attributes\": {\n              \"href\": \"https://appflowy.com/\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 3,\n        \"delta\": [\n          {\n            \"insert\": \"Basics\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": true,\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"insert\": \"+\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" button next to a Space name to quickly add a new page.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Click anywhere and just start typing.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Highlight \",\n            \"attributes\": {\n              \"bg_color\": \"0x4dffeb3b\"\n            }\n          },\n          {\n            \"insert\": \"any text, and use the editing menu to \"\n          },\n          {\n            \"insert\": \"style\",\n            \"attributes\": {\n              \"italic\": true\n            }\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"insert\": \"your\",\n            \"attributes\": {\n              \"bold\": true\n            }\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"insert\": \"writing\",\n            \"attributes\": {\n              \"underline\": true\n            }\n          },\n          {\n            \"insert\": \" \"\n          },\n          {\n            \"insert\": \"however\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" you \"\n          },\n          {\n            \"insert\": \"like.\",\n            \"attributes\": {\n              \"strikethrough\": true\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"children\": [\n        {\n          \"type\": \"todo_list\",\n          \"data\": {\n            \"checked\": false,\n            \"delta\": [\n              {\n                \"insert\": \"Type \"\n              },\n              {\n                \"insert\": \"/\",\n                \"attributes\": {\n                  \"code\": true\n                }\n              },\n              {\n                \"insert\": \" followed by \"\n              },\n              {\n                \"insert\": \"/bullet\",\n                \"attributes\": {\n                  \"code\": true\n                }\n              },\n              {\n                \"insert\": \" or \"\n              },\n              {\n                \"insert\": \"/num\",\n                \"attributes\": {\n                  \"code\": true\n                }\n              },\n              {\n                \"insert\": \" to create a list. \",\n                \"attributes\": {\n                  \"code\": false\n                }\n              }\n            ]\n          }\n        }\n      ],\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"As soon as you type \"\n          },\n          {\n            \"insert\": \"/\",\n            \"attributes\": {\n              \"font_color\": \"0xff00b5ff\",\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" a menu will pop up. Select \"\n          },\n          {\n            \"insert\": \"different types\",\n            \"attributes\": {\n              \"bg_color\": \"0x4d9c27b0\"\n            }\n          },\n          {\n            \"insert\": \" of content blocks you can add.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"insert\": \"+\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" next to any page title or space name in the sidebar to add a new page/subpage.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 3,\n        \"delta\": [\n          {\n            \"insert\": \"Quick Note\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"callout\",\n      \"data\": {\n        \"bgColor\": \"0x0\",\n        \"icon\": \"📌\",\n        \"delta\": [\n          {\n            \"insert\": \"Use Quick Note to save anything you want to remember—such as meeting notes, ideas, or to-dos.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"CMD\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" / \"\n          },\n          {\n            \"insert\": \"CTRL\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" + \"\n          },\n          {\n            \"insert\": \"/\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" to quickly add a quick note ✍️\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"image_type\": 1,\n        \"width\": 553.7578125,\n        \"align\": \"left\",\n        \"url\": \"https://beta.appflowy.cloud/api/file_storage/c29fafc4-b7c0-4549-8702-71339b0fd9ea/v1/blob/735c7c28%2D33a0%2D4e9f%2D92e6%2D089630656063/P5aRDW-HGm6vk4gjZg8w6jgD_z2yE402OuI1uutNN3A=.png\"\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Drag the notepad to the desired location.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"url\": \"https://beta.appflowy.cloud/api/file_storage/c29fafc4-b7c0-4549-8702-71339b0fd9ea/v1/blob/735c7c28%2D33a0%2D4e9f%2D92e6%2D089630656063/TRYYtsRlWsmEYPSsIkpYs0uawhrtj3W52kg0VgwZ2J0=.png\",\n        \"align\": \"center\",\n        \"image_type\": 1\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Expand the notepad into a larger window to get more editing space.\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"image\",\n      \"data\": {\n        \"image_type\": 1,\n        \"url\": \"https://beta.appflowy.cloud/api/file_storage/c29fafc4-b7c0-4549-8702-71339b0fd9ea/v1/blob/735c7c28%2D33a0%2D4e9f%2D92e6%2D089630656063/JimDFWOY8_ATw20LkDuBg68TwkwtmSysbRbMd2eNjZ0=.png\",\n        \"align\": \"center\"\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 3,\n        \"delta\": [\n          {\n            \"insert\": \"Keyboard shortcuts, markdown, and code block\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Keyboard shortcuts \"\n          },\n          {\n            \"insert\": \"guide\",\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Markdown \"\n          },\n          {\n            \"insert\": \"reference\",\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/markdown\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Type \"\n          },\n          {\n            \"insert\": \"/code\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" or \"\n          },\n          {\n            \"insert\": \"```\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" to insert a code block (Mermaid\"\n          },\n          {\n            \"insert\": \" is supported in our web app 🧜‍♀️)\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"code\",\n      \"data\": {\n        \"language\": \"mermaid\",\n        \"delta\": [\n          {\n            \"insert\": \"graph LR\\n    A[Square Rect] -- Link text --> B((Circle))\\n    A --> C(Round Rect)\\n    B --> D{Rhombus}\\n    C --> D\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"paragraph\",\n      \"data\": {\n        \"delta\": []\n      }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 2,\n        \"delta\": [\n          {\n            \"insert\": \"Have a question❓\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"quote\",\n      \"data\": {\n        \"delta\": [\n          {\n            \"insert\": \"Click \"\n          },\n          {\n            \"insert\": \"?\",\n            \"attributes\": {\n              \"code\": true\n            }\n          },\n          {\n            \"insert\": \" at the bottom right for help and support\"\n          }\n        ]\n      }\n    }\n  ],\n  \"data\": {\n    \"selected_icon\": \"{\\\"groupName\\\":\\\"Recent\\\",\\\"iconContent\\\":\\\"<svg width=\\\\\\\"14\\\\\\\" height=\\\\\\\"14\\\\\\\" viewBox=\\\\\\\"0 0 14 14\\\\\\\" fill=\\\\\\\"none\\\\\\\" xmlns=\\\\\\\"http://www.w3.org/2000/svg\\\\\\\">\\\\n<g clip-path=\\\\\\\"url(#clip0_1068_189559)\\\\\\\">\\\\n<path fill-rule=\\\\\\\"evenodd\\\\\\\" clip-rule=\\\\\\\"evenodd\\\\\\\" d=\\\\\\\"M9.81502 0.666197C9.9981 -0.170135 11.189 -0.175341 11.3793 0.65936L11.3883 0.698818C11.3944 0.725719 11.4 0.75057 11.4061 0.776468C11.6254 1.71056 12.3814 2.4243 13.3276 2.58893C14.2 2.74069 14.2 3.99292 13.3276 4.14468C12.3763 4.31019 11.6174 5.03067 11.4026 5.97212L11.3793 6.07425C11.189 6.90896 9.99809 6.90374 9.81502 6.06741L9.79583 5.97977C9.58899 5.03487 8.83104 4.30902 7.87808 4.14323C7.0074 3.99176 7.00739 2.74185 7.87808 2.59038C8.82772 2.42516 9.58371 1.70378 9.79365 0.763705L9.80781 0.699221L9.81502 0.666197ZM7.69172 5.21435C5.81589 4.88801 5.63841 2.41899 7.1594 1.68728C7.0236 1.44232 6.87994 1.20164 6.72859 0.965667C6.64978 0.962837 6.57062 0.961411 6.49113 0.961411C6.41169 0.961411 6.33258 0.962835 6.25382 0.965661C5.12844 2.72022 4.42868 4.7349 4.22016 6.83759H8.76225C8.7325 6.53763 8.69276 6.23947 8.64321 5.9436C8.46716 5.56473 8.11481 5.28796 7.69172 5.21435ZM0.0195433 6.83765C0.274572 4.16392 2.14899 1.96256 4.65082 1.22555C3.72044 2.95001 3.14382 4.86059 2.96455 6.83759H0.0284221L0.0195433 6.83765ZM0.0284221 8.08759H2.96451C3.1437 10.0646 3.72025 11.9752 4.65057 13.6997C2.14882 12.9626 0.274503 10.7612 0.0195312 8.08752L0.0284221 8.08759ZM4.22011 8.08759C4.42855 10.1903 5.12824 12.205 6.25357 13.9596C6.3324 13.9625 6.4116 13.9639 6.49113 13.9639C6.57071 13.9639 6.64996 13.9625 6.72885 13.9596C7.85417 12.205 8.55386 10.1903 8.7623 8.08759H4.22011ZM12.9628 8.08753C12.7078 10.7612 10.8336 12.9625 8.33187 13.6996C9.26217 11.9751 9.83871 10.0646 10.0179 8.08759H12.9542L12.9628 8.08753Z\\\\\\\" fill=\\\\\\\"black\\\\\\\"/>\\\\n</g>\\\\n<defs>\\\\n<clipPath id=\\\\\\\"clip0_1068_189559\\\\\\\">\\\\n<rect width=\\\\\\\"14\\\\\\\" height=\\\\\\\"14\\\\\\\" fill=\\\\\\\"white\\\\\\\"/>\\\\n</clipPath>\\\\n</defs>\\\\n</svg>\\\\n\\\",\\\"iconName\\\":\\\"ai-network-spark\\\",\\\"color\\\":\\\"0xFF808080\\\"}\",\n    \"image_type\": \"1\"\n  }\n}\n"
  },
  {
    "path": "libs/workspace-template/src/database/database_collab.rs",
    "content": "use anyhow::Error;\nuse collab::core::collab::default_client_id;\nuse collab_database::database::{Database, DatabaseContext};\nuse collab_database::database_trait::NoPersistenceDatabaseCollabService;\nuse collab_database::entity::{CreateDatabaseParams, EncodedDatabase};\nuse std::sync::Arc;\n\npub async fn create_database_collab(\n  params: CreateDatabaseParams,\n) -> Result<EncodedDatabase, Error> {\n  let collab_service = Arc::new(NoPersistenceDatabaseCollabService::new(default_client_id()));\n  let context = DatabaseContext {\n    database_collab_service: collab_service.clone(),\n    notifier: Default::default(),\n    database_row_collab_service: collab_service,\n  };\n  let database = Database::create_with_view(params, context).await?;\n  database\n    .encode_database_collabs()\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to encode database collabs: {:?}\", e))\n}\n"
  },
  {
    "path": "libs/workspace-template/src/database/mod.rs",
    "content": "pub mod database_collab;\n"
  },
  {
    "path": "libs/workspace-template/src/document/getting_started.rs",
    "content": "use std::collections::HashMap;\n\nuse anyhow::Error;\nuse async_trait::async_trait;\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\n\nuse collab::preclude::Collab;\nuse collab_database::database::{timestamp, DatabaseData};\nuse collab_database::entity::CreateDatabaseParams;\nuse collab_document::blocks::DocumentData;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::ViewLayout;\nuse serde_json::Value;\n\nuse crate::document::parser::JsonToDocumentParser;\nuse crate::document::util::{create_database_from_params, create_document_from_json};\nuse crate::hierarchy_builder::{ViewBuilder, WorkspaceViewBuilder};\nuse crate::{gen_view_id, TemplateData, TemplateObjectId, WorkspaceTemplate};\n\n// Template Folder Structure:\n// |-- General (space)\n//     |-- Getting started (document)\n//          |-- Desktop guide (document)\n//          |-- Mobile guide (document)\n//          |-- Web guide (document)\n//     |-- To-dos (board)\n// |-- Shared (space)\n//     |-- ... (empty)\n// Note: Update the folder structure above if you changed the code below\npub struct GettingStartedTemplate;\n\nimpl GettingStartedTemplate {\n  /// Create a document template data from the given JSON string\n  ///\n  /// Create a series of database templates from the given JSON String\n  ///\n  /// Notes: The output contains DatabaseCollab, DatabaseRowCollab\n  #[allow(clippy::too_many_arguments)]\n  async fn create_document_and_database_data(\n    &self,\n    general_view_uuid: String,\n    shared_view_uuid: String,\n    getting_started_view_uuid: String,\n    desktop_guide_view_uuid: String,\n    mobile_guide_view_uuid: String,\n    web_guide_view_uuid: String,\n    todos_view_uuid: String,\n  ) -> anyhow::Result<(\n    TemplateData,\n    TemplateData,\n    TemplateData,\n    TemplateData,\n    TemplateData,\n    TemplateData,\n    Vec<TemplateData>,\n  )> {\n    let default_space_json = include_str!(\"../../assets/default_space.json\");\n    let general_data =\n      create_document_from_json(general_view_uuid.clone(), default_space_json).await?;\n\n    let shared_data =\n      create_document_from_json(shared_view_uuid.clone(), default_space_json).await?;\n\n    let getting_started_json = include_str!(\"../../assets/getting_started.json\");\n    let mut getting_started_json: Value = serde_json::from_str(getting_started_json).unwrap();\n    let mut replacements = HashMap::new();\n    replacements.insert(\n      \"desktop_guide_id\".to_string(),\n      desktop_guide_view_uuid.clone(),\n    );\n    replacements.insert(\n      \"mobile_guide_id\".to_string(),\n      mobile_guide_view_uuid.clone(),\n    );\n    replacements.insert(\"web_guide_id\".to_string(), web_guide_view_uuid.clone());\n    replacements.insert(\"todos_id\".to_string(), todos_view_uuid.clone());\n    replace_json_placeholders(&mut getting_started_json, &replacements);\n    let getting_started_data = create_document_from_json(\n      getting_started_view_uuid.clone(),\n      &getting_started_json.to_string(),\n    )\n    .await?;\n\n    let desktop_guide_json = include_str!(\"../../assets/desktop_guide.json\");\n    let desktop_guide_data =\n      create_document_from_json(desktop_guide_view_uuid.clone(), desktop_guide_json).await?;\n\n    let mobile_guide_json = include_str!(\"../../assets/mobile_guide.json\");\n    let mobile_guide_data =\n      create_document_from_json(mobile_guide_view_uuid.clone(), mobile_guide_json).await?;\n\n    let web_guide_json = include_str!(\"../../assets/web_guide.json\");\n    let web_guide_data =\n      create_document_from_json(web_guide_view_uuid.clone(), web_guide_json).await?;\n\n    let todos_json = include_str!(\"../../assets/to-dos.json\");\n    let database_data = serde_json::from_str::<DatabaseData>(todos_json)?;\n    let database_view_id = database_data.views[0].id.clone();\n    let create_database_params =\n      CreateDatabaseParams::from_database_data(database_data, &database_view_id, &todos_view_uuid);\n    let todos_data =\n      create_database_from_params(todos_view_uuid.clone(), create_database_params.clone()).await?;\n\n    Ok((\n      general_data,\n      shared_data,\n      getting_started_data,\n      desktop_guide_data,\n      mobile_guide_data,\n      web_guide_data,\n      todos_data,\n    ))\n  }\n\n  async fn build_getting_started_view(\n    &self,\n    view_builder: ViewBuilder,\n    getting_started_view_uuid: String,\n    desktop_guide_view_uuid: String,\n    mobile_guide_view_uuid: String,\n    web_guide_view_uuid: String,\n  ) -> ViewBuilder {\n    // getting started view\n    let mut view_builder = view_builder\n      .with_name(\"Getting started\")\n      .with_icon(\"🌟\")\n      .with_extra(r#\"{\"font_layout\":\"normal\",\"line_height_layout\":\"normal\",\"cover\":{\"type\":\"gradient\",\"value\":\"appflowy_them_color_gradient4\"},\"font\":null}\"#)\n      .with_view_id(getting_started_view_uuid);\n\n    view_builder = view_builder\n      .with_child_view_builder({\n        |child_view_builder| async {\n          // desktop guide view\n          let desktop_guide_view_uuid = desktop_guide_view_uuid.clone();\n          child_view_builder\n            .with_name(\"Desktop guide\")\n            .with_icon(\"📎\")\n            .with_view_id(desktop_guide_view_uuid)\n            .build()\n        }\n      })\n      .await;\n\n    view_builder = view_builder\n      .with_child_view_builder({\n        |child_view_builder| async {\n          // mobile guide view\n          let mobile_guide_view_uuid = mobile_guide_view_uuid.clone();\n          child_view_builder\n            .with_name(\"Mobile guide\")\n            .with_view_id(mobile_guide_view_uuid)\n            .build()\n        }\n      })\n      .await;\n\n    view_builder = view_builder\n      .with_child_view_builder({\n        |child_view_builder| async {\n          // web guide view\n          let web_guide_view_uuid = web_guide_view_uuid.clone();\n          child_view_builder\n            .with_name(\"Web guide\")\n            .with_view_id(web_guide_view_uuid)\n            .build()\n        }\n      })\n      .await;\n\n    view_builder\n  }\n}\n\n#[async_trait]\nimpl WorkspaceTemplate for GettingStartedTemplate {\n  fn layout(&self) -> ViewLayout {\n    ViewLayout::Document\n  }\n\n  async fn create(&self, _object_id: String) -> anyhow::Result<Vec<TemplateData>> {\n    unreachable!(\"This function is not supposed to be called.\")\n  }\n\n  async fn create_workspace_view(\n    &self,\n    _uid: i64,\n    workspace_view_builder: &mut WorkspaceViewBuilder,\n  ) -> anyhow::Result<Vec<TemplateData>> {\n    let general_view_uuid = gen_view_id().to_string();\n    let shared_view_uuid = gen_view_id().to_string();\n    let getting_started_view_uuid = gen_view_id().to_string();\n    let desktop_guide_view_uuid = gen_view_id().to_string();\n    let mobile_guide_view_uuid = gen_view_id().to_string();\n    let web_guide_view_uuid = gen_view_id().to_string();\n    let todos_view_uuid = gen_view_id().to_string();\n\n    let (\n      general_data,\n      shared_data,\n      getting_started_data,\n      desktop_guide_data,\n      mobile_guide_data,\n      web_guide_data,\n      todos_data,\n    ) = self\n      .create_document_and_database_data(\n        general_view_uuid.clone(),\n        shared_view_uuid.clone(),\n        getting_started_view_uuid.clone(),\n        desktop_guide_view_uuid.clone(),\n        mobile_guide_view_uuid.clone(),\n        web_guide_view_uuid.clone(),\n        todos_view_uuid.clone(),\n      )\n      .await?;\n\n    // Create general space with 2 built-in views: Getting started, To-dos\n    //    The Getting started view is a document view, and the To-dos view is a board view\n    //    The Getting started view contains 2 sub views: Desktop guide, Mobile guide\n    workspace_view_builder\n      .with_view_builder(|view_builder| async {\n        let created_at = timestamp();\n        let mut view_builder = view_builder\n          .with_view_id(general_view_uuid.clone())\n          .with_name(\"General\")\n          .with_extra(&format!(\n              \"{{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":{}}}\",\n              created_at\n          ));\n\n        view_builder = view_builder.with_child_view_builder(\n          |child_view_builder| async {\n            let getting_started_view_uuid = getting_started_view_uuid.clone();\n            let desktop_guide_view_uuid = desktop_guide_view_uuid.clone();\n            let mobile_guide_view_uuid = mobile_guide_view_uuid.clone();\n            let web_guide_view_uuid = web_guide_view_uuid.clone();\n            let  child_view_builder = self.build_getting_started_view(child_view_builder, getting_started_view_uuid, desktop_guide_view_uuid, mobile_guide_view_uuid, web_guide_view_uuid).await;\n            child_view_builder.build()\n          }\n        ).await;\n\n        view_builder = view_builder.with_child_view_builder(\n          |child_view_builder| async {\n            let child_view_builder = child_view_builder\n            .with_layout(ViewLayout::Board)\n            .with_view_id(todos_view_uuid.clone())\n            .with_name(\"To-dos\")\n            .with_icon(\"✅\");\n            child_view_builder.build()\n          }\n        ).await;\n\n        view_builder.build()\n      })\n      .await;\n\n    // Create shared space without any built-in views\n    workspace_view_builder\n      .with_view_builder(|view_builder| async {\n        let created_at = timestamp();\n        let view_builder = view_builder\n        .with_view_id(shared_view_uuid.clone())\n        .with_name(\"Shared\")\n        .with_extra(&format!(\n            \"{{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/star-2\\\",\\\"space_icon_color\\\":\\\"0xFFFFBA00\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":{}}}\",\n            created_at\n        ));\n\n        view_builder.build()\n      })\n      .await;\n\n    let mut template_data = vec![\n      general_data,\n      shared_data,\n      getting_started_data,\n      desktop_guide_data,\n      mobile_guide_data,\n      web_guide_data,\n    ];\n    template_data.extend(todos_data);\n    Ok(template_data)\n  }\n}\n\npub enum DocumentTemplateContent {\n  Json(String),\n  Data(DocumentData),\n}\n\n/// Create a document with the given content\npub struct DocumentTemplate(DocumentData);\n\nimpl DocumentTemplate {\n  pub fn from_json(json: &str) -> Result<Self, Error> {\n    let data = JsonToDocumentParser::json_str_to_document(json)?;\n    Ok(Self(data))\n  }\n\n  pub fn from_data(data: DocumentData) -> Self {\n    Self(data)\n  }\n}\n\n#[async_trait]\nimpl WorkspaceTemplate for DocumentTemplate {\n  fn layout(&self) -> ViewLayout {\n    ViewLayout::Document\n  }\n\n  async fn create(&self, object_id: String) -> anyhow::Result<Vec<TemplateData>> {\n    let options = CollabOptions::new(object_id.clone(), default_client_id());\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n    let document = Document::create_with_data(collab, self.0.clone())?;\n    let data = document.encode_collab()?;\n    Ok(vec![TemplateData {\n      template_id: TemplateObjectId::Document(object_id),\n      collab_type: CollabType::Document,\n      encoded_collab: data,\n    }])\n  }\n\n  async fn create_workspace_view(\n    &self,\n    _uid: i64,\n    workspace_view_builder: &mut WorkspaceViewBuilder,\n  ) -> anyhow::Result<Vec<TemplateData>> {\n    let view_id = gen_view_id().to_string();\n\n    workspace_view_builder\n      .with_view_builder(|view_builder| async {\n        view_builder\n          .with_name(\"Getting started\")\n          .with_icon(\"⭐️\")\n          .with_view_id(view_id.clone())\n          .build()\n      })\n      .await;\n\n    self.create(view_id).await\n  }\n}\n\npub fn getting_started_document_data() -> Result<DocumentData, Error> {\n  let json_str = include_str!(\"../../assets/getting_started.json\");\n  JsonToDocumentParser::json_str_to_document(json_str)\n}\n\npub fn desktop_guide_document_data() -> Result<DocumentData, Error> {\n  let json_str = include_str!(\"../../assets/desktop_guide.json\");\n  JsonToDocumentParser::json_str_to_document(json_str)\n}\n\npub fn mobile_guide_document_data() -> Result<DocumentData, Error> {\n  let json_str = include_str!(\"../../assets/mobile_guide.json\");\n  JsonToDocumentParser::json_str_to_document(json_str)\n}\n\npub fn get_initial_document_data() -> Result<DocumentData, Error> {\n  let json_str = include_str!(\"../../assets/initial_document.json\");\n  JsonToDocumentParser::json_str_to_document(json_str)\n}\n\n/// Replace the placeholders in the JSON value with the given replacements.\n///\n/// The placeholders are in the format of \"<key>\", for example \"<name>\".\n/// The value of the placeholder will be replaced with the value of the key in the replacements map.\npub fn replace_json_placeholders(value: &mut Value, replacements: &HashMap<String, String>) {\n  match value {\n    Value::String(s) => {\n      if s.starts_with('<') && s.ends_with('>') {\n        let key = s.trim_start_matches('<').trim_end_matches('>');\n        if let Some(replacement) = replacements.get(key) {\n          *s = replacement.to_string();\n        }\n      }\n    },\n    Value::Array(arr) => {\n      for item in arr {\n        replace_json_placeholders(item, replacements);\n      }\n    },\n    Value::Object(obj) => {\n      for (_, v) in obj {\n        replace_json_placeholders(v, replacements);\n      }\n    },\n    _ => {},\n  }\n}\n"
  },
  {
    "path": "libs/workspace-template/src/document/mod.rs",
    "content": "pub mod getting_started;\npub mod parser;\npub mod util;\npub mod vault_template;\n"
  },
  {
    "path": "libs/workspace-template/src/document/parser.rs",
    "content": "use std::collections::HashMap;\n\nuse anyhow::Result;\nuse collab_document::blocks::{Block, DocumentData, DocumentMeta};\nuse indexmap::IndexMap;\nuse nanoid::nanoid;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\npub struct JsonToDocumentParser;\n\nconst DELTA: &str = \"delta\";\nconst TEXT_EXTERNAL_TYPE: &str = \"text\";\nimpl JsonToDocumentParser {\n  pub fn serde_block_to_document(root: SerdeBlock) -> Result<DocumentData> {\n    let page_id = nanoid!(10);\n\n    // generate the blocks\n    // the root's parent id is empty\n    let (blocks, text_map) = Self::generate_blocks(&root, Some(page_id.clone()), \"\".to_string());\n\n    // generate the children map\n    let children_map = Self::generate_children_map(&blocks);\n\n    // generate the text map\n    let text_map = Self::generate_text_map(&text_map);\n    Ok(DocumentData {\n      page_id,\n      blocks: blocks.into_iter().collect(),\n      meta: DocumentMeta {\n        children_map,\n        text_map: Some(text_map),\n      },\n    })\n  }\n\n  pub fn json_str_to_document(json_str: &str) -> Result<DocumentData> {\n    let root = serde_json::from_str::<SerdeBlock>(json_str)?;\n    Self::serde_block_to_document(root)\n  }\n\n  pub fn json_to_document(json_value: serde_json::Value) -> Result<DocumentData> {\n    let root = serde_json::from_value::<SerdeBlock>(json_value)?;\n    Self::serde_block_to_document(root)\n  }\n\n  pub fn generate_blocks(\n    block: &SerdeBlock,\n    id: Option<String>,\n    parent_id: String,\n  ) -> (IndexMap<String, Block>, IndexMap<String, String>) {\n    let (block_pb, delta) = Self::block_to_block_pb(block, id, parent_id);\n    let block_id = block_pb.id.clone();\n    let external_id = block_pb.external_id.clone();\n    let mut blocks = IndexMap::new();\n    blocks.insert(block_pb.id.clone(), block_pb);\n    let mut text_map = IndexMap::new();\n    for child in &block.children {\n      let (child_blocks, child_blocks_text_map) =\n        Self::generate_blocks(child, None, block_id.clone());\n      blocks.extend(child_blocks);\n      text_map.extend(child_blocks_text_map);\n    }\n    if let Some(delta) = delta {\n      if let Some(external_id) = external_id {\n        text_map.insert(external_id, delta);\n      }\n    }\n    (blocks, text_map)\n  }\n\n  fn generate_text_map(text_map: &IndexMap<String, String>) -> HashMap<String, String> {\n    text_map\n      .iter()\n      .map(|(k, v)| (k.clone(), v.clone()))\n      .collect()\n  }\n\n  pub fn generate_children_map(blocks: &IndexMap<String, Block>) -> HashMap<String, Vec<String>> {\n    let mut children_map = HashMap::new();\n    for (id, block) in blocks.iter() {\n      // add itself to it's parent's children\n      if block.parent.is_empty() {\n        continue;\n      }\n      let parent_block = blocks.get(&block.parent);\n      if let Some(parent_block) = parent_block {\n        // insert itself to it's parent's children\n        let children = children_map\n          .entry(parent_block.children.clone())\n          .or_insert_with(Vec::new);\n        children.push(id.clone());\n        // create a children map entry for itself\n        children_map\n          .entry(block.children.clone())\n          .or_insert_with(Vec::new);\n      }\n    }\n    children_map\n  }\n\n  fn block_to_block_pb(\n    block: &SerdeBlock,\n    id: Option<String>,\n    parent: String,\n  ) -> (Block, Option<String>) {\n    let id = id.unwrap_or_else(|| nanoid!(10));\n    let mut data = block.data.clone();\n    let delta = data.remove(DELTA).map(|d| d.to_string());\n    let (external_id, external_type) = match delta {\n      None => (None, None),\n      Some(_) => (Some(nanoid!(10)), Some(TEXT_EXTERNAL_TYPE.to_string())),\n    };\n\n    (\n      Block {\n        id,\n        ty: block.ty.clone(),\n        data,\n        parent,\n        children: nanoid!(10),\n        external_id,\n        external_type,\n      },\n      delta,\n    )\n  }\n}\n\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct SerdeBlock {\n  #[serde(rename = \"type\")]\n  pub ty: String,\n  #[serde(default)]\n  pub data: HashMap<String, Value>,\n  #[serde(default)]\n  pub children: Vec<SerdeBlock>,\n}\n"
  },
  {
    "path": "libs/workspace-template/src/document/util.rs",
    "content": "use crate::database::database_collab::create_database_collab;\nuse crate::document::parser::JsonToDocumentParser;\nuse crate::{TemplateData, TemplateObjectId};\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse collab_database::entity::CreateDatabaseParams;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\n\npub async fn create_document_from_json(\n  object_id: String,\n  json_str: &str,\n) -> anyhow::Result<TemplateData> {\n  // 1. read the getting started document from the assets\n  let document_data = JsonToDocumentParser::json_str_to_document(json_str)?;\n\n  // 2. create a new document with the getting started data\n  let data = tokio::task::spawn_blocking(move || {\n    let options = CollabOptions::new(object_id.clone(), default_client_id());\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n    let document = Document::create_with_data(collab, document_data)?;\n    let encoded_collab = document.encode_collab()?;\n\n    Ok::<_, anyhow::Error>(TemplateData {\n      template_id: TemplateObjectId::Document(object_id),\n      collab_type: CollabType::Document,\n      encoded_collab,\n    })\n  })\n  .await??;\n\n  Ok(data)\n}\n\n/// Create a series of database templates from the given JSON String\n///\n/// Notes: The output contains DatabaseCollab, DatabaseRowCollab\npub async fn create_database_from_params(\n  object_id: String,\n  create_database_params: CreateDatabaseParams,\n) -> anyhow::Result<Vec<TemplateData>> {\n  let database_id = create_database_params.database_id.clone();\n  let encoded_database = create_database_collab(create_database_params).await?;\n\n  let encoded_database_collab = encoded_database\n    .encoded_database_collab\n    .encoded_collab\n    .clone();\n\n  // 1. create the new database collab\n  let database_template_data = TemplateData {\n    template_id: TemplateObjectId::Database {\n      object_id,\n      database_id: database_id.clone(),\n    },\n    collab_type: CollabType::Database,\n    encoded_collab: encoded_database_collab,\n  };\n\n  // 2. create the new database row collabs\n  let database_row_template_data =\n    encoded_database\n      .encoded_row_collabs\n      .into_iter()\n      .map(|encoded_row_collab| TemplateData {\n        template_id: TemplateObjectId::DatabaseRow(encoded_row_collab.object_id.to_string()),\n        collab_type: CollabType::DatabaseRow,\n        encoded_collab: encoded_row_collab.encoded_collab,\n      });\n\n  let mut template_data = vec![database_template_data];\n  template_data.extend(database_row_template_data);\n\n  Ok(template_data)\n}\n"
  },
  {
    "path": "libs/workspace-template/src/document/vault_template.rs",
    "content": "use async_trait::async_trait;\nuse collab_database::database::timestamp;\nuse collab_folder::ViewLayout;\nuse serde_json::Value;\n\nuse crate::document::util::create_document_from_json;\nuse crate::hierarchy_builder::{ViewBuilder, WorkspaceViewBuilder};\nuse crate::{gen_view_id, TemplateData, WorkspaceTemplate};\n\npub struct VaultTemplate;\n\nimpl VaultTemplate {\n  async fn create_generate(&self, general_view_uuid: String) -> anyhow::Result<TemplateData> {\n    let default_space_json = include_str!(\"../../assets/default_space.json\");\n    let general_data =\n      create_document_from_json(general_view_uuid.clone(), default_space_json).await?;\n\n    Ok(general_data)\n  }\n\n  async fn create_vault_get_started(&self, object_id: String) -> anyhow::Result<TemplateData> {\n    let getting_started_json = include_str!(\"../../assets/vault_get_started.json\");\n    let getting_started_json: Value = serde_json::from_str(getting_started_json)?;\n    let getting_started_data =\n      create_document_from_json(object_id.clone(), &getting_started_json.to_string()).await?;\n\n    Ok(getting_started_data)\n  }\n\n  async fn build_getting_started_view(\n    &self,\n    view_builder: ViewBuilder,\n    getting_started_view_uuid: String,\n  ) -> ViewBuilder {\n    // getting started view\n    view_builder\n            .with_name(\"Vault Mode\")\n            .with_icon(\"🏦\")\n            .with_extra(r#\"{\"font_layout\":\"normal\",\"line_height_layout\":\"normal\",\"cover\":{\"type\":\"gradient\",\"value\":\"appflowy_them_color_gradient4\"},\"font\":null}\"#)\n            .with_view_id(getting_started_view_uuid)\n  }\n}\n\n#[async_trait]\nimpl WorkspaceTemplate for VaultTemplate {\n  fn layout(&self) -> ViewLayout {\n    ViewLayout::Document\n  }\n\n  async fn create(&self, _object_id: String) -> anyhow::Result<Vec<TemplateData>> {\n    unreachable!(\"This function is not supposed to be called.\")\n  }\n\n  async fn create_workspace_view(\n    &self,\n    _uid: i64,\n    workspace_view_builder: &mut WorkspaceViewBuilder,\n  ) -> anyhow::Result<Vec<TemplateData>> {\n    let general_view_uuid = gen_view_id().to_string();\n    let getting_started_uuid = gen_view_id().to_string();\n\n    let general_data = self.create_generate(general_view_uuid.clone()).await?;\n    let get_started_data = self\n      .create_vault_get_started(getting_started_uuid.clone())\n      .await?;\n    workspace_view_builder\n      .with_view_builder(|view_builder| async {\n        let created_at = timestamp();\n        let mut view_builder = view_builder\n         .with_view_id(general_view_uuid.clone())\n         .with_name(\"General\")\n         .with_extra(&format!(\n             \"{{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":1,\\\"space_created_at\\\":{}}}\",\n             created_at\n         ));\n\n          let cloned_getting_started_uuid = getting_started_uuid.clone();\n         view_builder = view_builder.with_child_view_builder(|child_view_builder| async {\n             let child_view_builder = self.build_getting_started_view(child_view_builder, cloned_getting_started_uuid.clone()).await;\n             child_view_builder.build()\n         }).await;\n\n        view_builder.build()\n      })\n      .await;\n\n    let template_data = vec![general_data, get_started_data];\n    Ok(template_data)\n  }\n}\n"
  },
  {
    "path": "libs/workspace-template/src/hierarchy_builder.rs",
    "content": "use crate::gen_view_id;\nuse collab_folder::{\n  timestamp, IconType, RepeatedViewIdentifier, View, ViewIcon, ViewIdentifier, ViewLayout,\n};\nuse std::future::Future;\n\n/// A builder for creating a view for a workspace.\n/// The views created by this builder will be the first level views of the workspace.\npub struct WorkspaceViewBuilder {\n  pub uid: i64,\n  pub workspace_id: String,\n  pub views: Vec<ParentChildViews>,\n}\n\nimpl WorkspaceViewBuilder {\n  pub fn new(workspace_id: String, uid: i64) -> Self {\n    Self {\n      uid,\n      workspace_id,\n      views: vec![],\n    }\n  }\n\n  pub async fn with_view_builder<F, O>(&mut self, view_builder: F) -> &mut Self\n  where\n    F: Fn(ViewBuilder) -> O,\n    O: Future<Output = ParentChildViews>,\n  {\n    let builder = ViewBuilder::new(self.uid, self.workspace_id.clone());\n    let view = view_builder(builder).await;\n    self.views.push(view);\n    self\n  }\n\n  pub fn build(&mut self) -> Vec<ParentChildViews> {\n    std::mem::take(&mut self.views)\n  }\n}\n\n/// A builder for creating a view.\n/// The default layout of the view is [ViewLayout::Document]\npub struct ViewBuilder {\n  uid: i64,\n  parent_view_id: String,\n  view_id: String,\n  name: String,\n  layout: ViewLayout,\n  child_views: Vec<ParentChildViews>,\n  is_favorite: bool,\n  icon: Option<ViewIcon>,\n  extra: Option<String>,\n}\n\nimpl ViewBuilder {\n  pub fn new(uid: i64, parent_view_id: String) -> Self {\n    Self {\n      uid,\n      parent_view_id,\n      view_id: gen_view_id().to_string(),\n      name: Default::default(),\n      layout: ViewLayout::Document,\n      child_views: vec![],\n      is_favorite: false,\n      icon: None,\n      extra: None,\n    }\n  }\n\n  pub fn view_id(&self) -> &str {\n    &self.view_id\n  }\n\n  pub fn with_view_id<T: ToString>(mut self, view_id: T) -> Self {\n    self.view_id = view_id.to_string();\n    self\n  }\n\n  pub fn with_layout(mut self, layout: ViewLayout) -> Self {\n    self.layout = layout;\n    self\n  }\n\n  pub fn with_name(mut self, name: &str) -> Self {\n    self.name = name.to_string();\n    self\n  }\n\n  pub fn with_icon(mut self, icon: &str) -> Self {\n    self.icon = Some(ViewIcon {\n      ty: IconType::Emoji,\n      value: icon.to_string(),\n    });\n    self\n  }\n\n  pub fn with_extra(mut self, extra: &str) -> Self {\n    self.extra = Some(extra.to_string());\n    self\n  }\n\n  /// Create a child view for the current view.\n  /// The view created by this builder will be the next level view of the current view.\n  pub async fn with_child_view_builder<F, O>(mut self, child_view_builder: F) -> Self\n  where\n    F: Fn(ViewBuilder) -> O,\n    O: Future<Output = ParentChildViews>,\n  {\n    let builder = ViewBuilder::new(self.uid, self.view_id.clone());\n    self.child_views.push(child_view_builder(builder).await);\n    self\n  }\n\n  pub fn build(self) -> ParentChildViews {\n    let view = View {\n      id: self.view_id,\n      parent_view_id: self.parent_view_id,\n      name: self.name,\n      created_at: timestamp(),\n      is_favorite: self.is_favorite,\n      layout: self.layout,\n      icon: self.icon,\n      created_by: Some(self.uid),\n      last_edited_time: 0,\n      children: RepeatedViewIdentifier::new(\n        self\n          .child_views\n          .iter()\n          .map(|v| ViewIdentifier {\n            id: v.parent_view.id.clone(),\n          })\n          .collect(),\n      ),\n      last_edited_by: Some(self.uid),\n      is_locked: None,\n      extra: self.extra,\n    };\n    ParentChildViews {\n      parent_view: view,\n      child_views: self.child_views,\n    }\n  }\n}\n\npub struct ParentChildViews {\n  pub parent_view: View,\n  pub child_views: Vec<ParentChildViews>,\n}\n\npub struct FlattedViews;\n\nimpl FlattedViews {\n  pub fn flatten_views(views: Vec<ParentChildViews>) -> Vec<View> {\n    let mut result = vec![];\n    for view in views {\n      result.push(view.parent_view);\n      result.append(&mut Self::flatten_views(view.child_views));\n    }\n    result\n  }\n}\n"
  },
  {
    "path": "libs/workspace-template/src/lib.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\n\npub use anyhow::Result;\nuse async_trait::async_trait;\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\n\nuse crate::hierarchy_builder::{FlattedViews, WorkspaceViewBuilder};\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse collab_folder::{\n  timestamp, Folder, FolderData, RepeatedViewIdentifier, ViewIdentifier, ViewLayout, Workspace,\n};\nuse uuid::Uuid;\n\npub mod database;\npub mod document;\n\npub mod hierarchy_builder;\n#[cfg(test)]\nmod tests;\n\n#[async_trait]\npub trait WorkspaceTemplate {\n  fn layout(&self) -> ViewLayout;\n\n  async fn create(&self, object_id: String) -> Result<Vec<TemplateData>>;\n\n  async fn create_workspace_view(\n    &self,\n    uid: i64,\n    workspace_view_builder: &mut WorkspaceViewBuilder,\n  ) -> Result<Vec<TemplateData>>;\n}\n\n#[derive(Clone, Debug)]\npub enum TemplateObjectId {\n  Folder(String),\n  Document(String),\n  DatabaseRow(String),\n  Database {\n    object_id: String,\n    // It's used to reference the database id from the object_id\n    database_id: String,\n  },\n}\n\npub struct TemplateData {\n  pub template_id: TemplateObjectId,\n  pub collab_type: CollabType,\n  pub encoded_collab: EncodedCollab,\n}\n\npub type WorkspaceTemplateHandlers = HashMap<ViewLayout, Arc<dyn WorkspaceTemplate + Send + Sync>>;\n\n/// A builder for creating a workspace template.\n/// workspace template is a set of views that are created when a workspace is created.\npub struct WorkspaceTemplateBuilder {\n  pub uid: i64,\n  pub workspace_id: String,\n  pub handlers: WorkspaceTemplateHandlers,\n}\n\nimpl WorkspaceTemplateBuilder {\n  pub fn new(uid: i64, workspace_id: &Uuid) -> Self {\n    let handlers = WorkspaceTemplateHandlers::default();\n    Self {\n      uid,\n      workspace_id: workspace_id.to_string(),\n      handlers,\n    }\n  }\n\n  pub fn with_template<T>(mut self, template: T) -> Self\n  where\n    T: WorkspaceTemplate + Send + Sync + 'static,\n  {\n    self.handlers.insert(template.layout(), Arc::new(template));\n    self\n  }\n\n  pub fn with_templates<T>(mut self, templates: Vec<T>) -> Self\n  where\n    T: WorkspaceTemplate + Send + Sync + 'static,\n  {\n    for template in templates {\n      self.handlers.insert(template.layout(), Arc::new(template));\n    }\n    self\n  }\n\n  pub async fn build(&self) -> Result<Vec<TemplateData>> {\n    let mut workspace_view_builder = WorkspaceViewBuilder::new(self.workspace_id.clone(), self.uid);\n    let mut templates: Vec<TemplateData> = vec![];\n    for handler in self.handlers.values() {\n      if let Ok(template) = handler\n        .create_workspace_view(self.uid, &mut workspace_view_builder)\n        .await\n      {\n        templates.extend(template);\n      }\n    }\n\n    // All views directly under the workspace should be space.\n    let views = workspace_view_builder.build();\n    // Safe to unwrap because we have at least one space with a document.\n    let default_current_view_id = views\n      .iter()\n      .find(|v| !v.child_views.is_empty())\n      .unwrap()\n      .child_views\n      .first()\n      .unwrap()\n      .parent_view\n      .id\n      .clone();\n\n    let first_level_views = views\n      .iter()\n      .map(|value| ViewIdentifier {\n        id: value.parent_view.id.clone(),\n      })\n      .collect::<Vec<_>>();\n\n    let workspace = Workspace {\n      id: self.workspace_id.clone(),\n      name: \"Workspace\".to_string(),\n      child_views: RepeatedViewIdentifier::new(first_level_views),\n      created_at: timestamp(),\n      created_by: Some(self.uid),\n      last_edited_time: timestamp(),\n      last_edited_by: Some(self.uid),\n    };\n\n    let uid = self.uid;\n    let workspace_id = self.workspace_id.clone();\n    let folder_template = tokio::task::spawn_blocking(move || {\n      let folder_data = FolderData {\n        uid,\n        workspace,\n        current_view: default_current_view_id,\n        views: FlattedViews::flatten_views(views),\n        favorites: Default::default(),\n        recent: Default::default(),\n        trash: Default::default(),\n        private: Default::default(),\n      };\n\n      let options = CollabOptions::new(workspace_id.clone(), default_client_id());\n      let collab = Collab::new_with_options(CollabOrigin::Empty, options)?;\n      let folder = Folder::create(collab, None, folder_data);\n      let data = folder.encode_collab()?;\n      Ok::<_, anyhow::Error>(TemplateData {\n        template_id: TemplateObjectId::Folder(workspace_id),\n        collab_type: CollabType::Folder,\n        encoded_collab: data,\n      })\n    })\n    .await??;\n\n    templates.push(folder_template);\n    Ok(templates)\n  }\n}\n\npub fn gen_view_id() -> Uuid {\n  uuid::Uuid::new_v4()\n}\n"
  },
  {
    "path": "libs/workspace-template/src/tests/getting_started_tests.rs",
    "content": "use std::collections::HashMap;\n\nuse collab::preclude::uuid_v4;\nuse collab_database::database::DatabaseData;\nuse collab_database::entity::CreateDatabaseParams;\nuse collab_document::document_data::generate_id;\nuse collab_entity::CollabType;\nuse serde_json::json;\n\nuse crate::document::getting_started::*;\nuse crate::TemplateData;\nuse crate::TemplateObjectId;\nuse crate::{hierarchy_builder::WorkspaceViewBuilder, WorkspaceTemplate};\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use crate::document::util::{create_database_from_params, create_document_from_json};\n  use collab_database::database::gen_database_view_id;\n\n  #[tokio::test]\n  async fn create_document_from_desktop_guide_json_test() {\n    let json_str = include_str!(\"../../assets/desktop_guide.json\");\n    test_document_json(json_str).await;\n  }\n\n  #[tokio::test]\n  async fn create_document_from_mobile_guide_json_test() {\n    let json_str = include_str!(\"../../assets/mobile_guide.json\");\n    test_document_json(json_str).await;\n  }\n\n  #[tokio::test]\n  async fn create_document_from_getting_started_json_test() {\n    let json_str = include_str!(\"../../assets/getting_started.json\");\n    test_document_json(json_str).await;\n  }\n\n  #[tokio::test]\n  async fn create_database_from_todos_json_test() {\n    let json_str = include_str!(\"../../assets/to-dos.json\");\n    let template_data = test_database_json(json_str).await;\n    // one database and 5 rows\n    assert_eq!(template_data.len(), 6);\n  }\n\n  async fn test_document_json(json_str: &str) {\n    let object_id = uuid_v4().to_string();\n    let result = create_document_from_json(object_id.clone(), json_str).await;\n    let template_data = result.unwrap();\n\n    match template_data.template_id {\n      TemplateObjectId::Document(oid) => {\n        assert_eq!(oid, object_id);\n      },\n      _ => {\n        panic!(\"Template data is not a document\");\n      },\n    }\n    assert_eq!(template_data.collab_type, CollabType::Document);\n    assert!(!template_data.encoded_collab.doc_state.is_empty());\n  }\n\n  async fn test_database_json(json_str: &str) -> Vec<TemplateData> {\n    let object_id = gen_database_view_id().to_string();\n    let database_data = serde_json::from_str::<DatabaseData>(json_str).unwrap();\n\n    let database_view_id = database_data.views[0].id.clone();\n    let create_database_params =\n      CreateDatabaseParams::from_database_data(database_data, &database_view_id, &object_id);\n    let result = create_database_from_params(object_id.clone(), create_database_params).await;\n    let template_data = result.unwrap();\n\n    for (i, data) in template_data.iter().enumerate() {\n      if i == 0 {\n        // The first item is the database\n        assert_eq!(data.collab_type, CollabType::Database);\n      } else {\n        // The rest are database rows\n        assert_eq!(data.collab_type, CollabType::DatabaseRow);\n      }\n\n      assert!(!data.encoded_collab.doc_state.is_empty());\n    }\n\n    template_data\n  }\n\n  #[tokio::test]\n  async fn create_workspace_view_with_getting_started_template_test() {\n    let template = GettingStartedTemplate;\n    let mut workspace_view_builder = WorkspaceViewBuilder::new(generate_id(), 1);\n\n    let result = template\n      .create_workspace_view(1, &mut workspace_view_builder)\n      .await\n      .unwrap();\n\n    // 2 spaces + 4 documents + 1 database + 5 database rows\n    assert_eq!(result.len(), 12);\n\n    let views = workspace_view_builder.build();\n\n    // check the number of spaces\n    assert_eq!(views.len(), 2);\n\n    let general_space = &views[0];\n    let shared_space = &views[1];\n\n    // General\n    assert_eq!(general_space.parent_view.name, \"General\");\n    // generate space contains 1 document and 1 database at the first level\n    assert_eq!(general_space.child_views.len(), 2);\n    // the first document contains 2 children\n    assert_eq!(general_space.child_views[0].child_views.len(), 3);\n    // the first database contains 0 children\n    assert_eq!(general_space.child_views[1].child_views.len(), 0);\n\n    // Shared\n    assert_eq!(shared_space.parent_view.name, \"Shared\");\n    // shared space is empty by default\n    assert!(shared_space.child_views.is_empty());\n  }\n\n  #[test]\n  fn replace_json_placeholders_test() {\n    let mut json_value = json!({\n        \"id\": \"<desktop_guide_view_id>\",\n        \"children\": [\"<referenced_view_id_1>\", \"<referenced_view_id_2>\"],\n        \"attributes\": {\n            \"key\": \"<value>\"\n        }\n    });\n\n    let mut replacements = HashMap::new();\n    replacements.insert(\"desktop_guide_view_id\".to_string(), \"1\".to_string());\n    replacements.insert(\"referenced_view_id_1\".to_string(), \"2\".to_string());\n    replacements.insert(\"referenced_view_id_2\".to_string(), \"3\".to_string());\n    replacements.insert(\"value\".to_string(), \"appflowy\".to_string());\n\n    replace_json_placeholders(&mut json_value, &replacements);\n\n    let expected = json!({\n        \"id\": \"1\",\n        \"children\": [\"2\", \"3\"],\n        \"attributes\": {\n            \"key\": \"appflowy\"\n        }\n    });\n\n    assert_eq!(json_value, expected);\n  }\n}\n"
  },
  {
    "path": "libs/workspace-template/src/tests/mod.rs",
    "content": "mod getting_started_tests;\n"
  },
  {
    "path": "migrations/20230312043024_user.sql",
    "content": "-- Add migration script here\n-- Required by uuid_generate_v4()\nCREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";\n-- user table\nCREATE TABLE IF NOT EXISTS af_user (\n    uid BIGINT PRIMARY KEY,\n    uuid UUID NOT NULL , -- related to gotrue\n    email TEXT NOT NULL DEFAULT '' UNIQUE, -- not needed when authenticated with gotrue\n    password TEXT NOT NULL DEFAULT '', -- not needed when authenticated with gotrue\n    name TEXT NOT NULL DEFAULT '',\n    metadata JSONB DEFAULT '{}'::JSONB,  -- used to user's metadata such as avatar, OpenAI key, etc.\n    encryption_sign TEXT DEFAULT NULL, -- used to encrypt the user's data\n    deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,\n    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\nCREATE OR REPLACE FUNCTION update_updated_at_column_func() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW();\nRETURN NEW;\nEND;\n$$ language 'plpgsql';\nCREATE OR REPLACE TRIGGER update_af_user_modtime BEFORE\nUPDATE ON af_user FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column_func();\nCREATE OR REPLACE FUNCTION prevent_reset_encryption_sign_func() RETURNS TRIGGER AS $$ BEGIN IF OLD.encryption_sign IS NOT NULL\n    AND NEW.encryption_sign IS DISTINCT\nFROM OLD.encryption_sign THEN RAISE EXCEPTION 'The encryption sign can not be reset once it has been set';\nEND IF;\nRETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\nCREATE OR REPLACE TRIGGER trigger_prevent_reset_encryption_sign BEFORE\nUPDATE ON af_user FOR EACH ROW EXECUTE FUNCTION prevent_reset_encryption_sign_func();\n"
  },
  {
    "path": "migrations/20230906101032_permission.sql",
    "content": "-- Create the af_roles table\nCREATE TABLE IF NOT EXISTS af_roles (\n    id SERIAL PRIMARY KEY,\n    name TEXT UNIQUE NOT NULL\n);\n-- Insert default roles\nINSERT INTO af_roles (name)\nVALUES ('Owner'),\n    ('Member'),\n    ('Guest');\nCREATE TABLE af_permissions (\n    id SERIAL PRIMARY KEY,\n    name VARCHAR(255) UNIQUE NOT NULL,\n    access_level INTEGER NOT NULL,\n    description TEXT\n);\n-- Insert default permissions\nINSERT INTO af_permissions (name, description, access_level)\nVALUES ('Read only', 'Can read', 10),\n    (\n        'Read and comment',\n        'Can read and comment, but not edit',\n        20\n    ),\n    (\n        'Read and write',\n        'Can read and edit, but not share with others',\n        30\n    ),\n    (\n        'Full access',\n        'Can edit and share with others',\n        50\n    );\n-- Represents a permission that a role has. The list of all permissions a role has can be obtained by querying this table for all rows with a given role_id.\nCREATE TABLE af_role_permissions (\n    role_id INT REFERENCES af_roles(id),\n    permission_id INT REFERENCES af_permissions(id),\n    PRIMARY KEY (role_id, permission_id)\n);\n-- Associate permissions with roles\nWITH role_ids AS (\n    SELECT id,\n        name\n    FROM af_roles\n    WHERE name IN ('Owner', 'Member', 'Guest')\n),\npermission_ids AS (\n    SELECT id,\n        name\n    FROM af_permissions\n    WHERE name IN ('Full access', 'Read and write', 'Read only')\n)\nINSERT INTO af_role_permissions (role_id, permission_id)\nSELECT r.id,\n    p.id\nFROM role_ids r\n    CROSS JOIN permission_ids p\nWHERE (\n        r.name = 'Owner'\n        AND p.name = 'Full access'\n    )\n    OR (\n        r.name = 'Member'\n        AND p.name = 'Read and write'\n    )\n    OR (\n        r.name = 'Guest'\n        AND p.name = 'Read only'\n    );"
  },
  {
    "path": "migrations/20230906101223_workspace.sql",
    "content": "-- af_workspace contains all the workspaces. Each workspace contains a list of members defined in af_workspace_member\nCREATE TABLE IF NOT EXISTS af_workspace (\n    workspace_id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(),\n    database_storage_id UUID NOT NULL DEFAULT uuid_generate_v4(),\n    owner_uid BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    -- 0: Free\n    workspace_type INTEGER NOT NULL DEFAULT 0,\n    deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,\n    workspace_name TEXT DEFAULT 'My Workspace'\n);\n\n-- af_workspace_member contains all the members associated with a workspace and their roles.\nCREATE TABLE IF NOT EXISTS af_workspace_member (\n    uid BIGINT NOT NULL,\n    role_id INT NOT NULL REFERENCES af_roles(id),\n    workspace_id UUID NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    PRIMARY KEY (uid, workspace_id)\n);\n\n-- Listener for af_workspace_member table\nCREATE OR REPLACE FUNCTION notify_af_workspace_member_change() RETURNS trigger AS $$\nDECLARE\n    payload TEXT;\nBEGIN\n    payload := json_build_object(\n            'old', row_to_json(OLD),\n            'new', row_to_json(NEW),\n            'action_type', TG_OP\n            )::text;\n\n    PERFORM pg_notify('af_workspace_member_channel', payload);\n    -- Return the new row state for INSERT/UPDATE, and the old state for DELETE.\n    IF TG_OP = 'DELETE' THEN\n        RETURN OLD;\n    ELSE\n        RETURN NEW;\n    END IF;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE TRIGGER af_workspace_member_change_trigger\n    AFTER INSERT OR UPDATE OR DELETE ON af_workspace_member\n    FOR EACH ROW EXECUTE FUNCTION notify_af_workspace_member_change();\n\n-- Index\nCREATE UNIQUE INDEX IF NOT EXISTS idx_af_workspace_member ON af_workspace_member (uid, workspace_id, role_id);\n-- Insert a workspace member if the user with given uid is the owner of the workspace\nCREATE OR REPLACE FUNCTION insert_af_workspace_member_if_owner(\n        p_uid BIGINT,\n        p_role_id INT,\n        p_workspace_id UUID\n    ) RETURNS VOID AS $$ BEGIN -- If user is the owner, proceed with the insert operation\nINSERT INTO af_workspace_member (uid, role_id, workspace_id)\nSELECT p_uid,\n       p_role_id,\n       p_workspace_id\nFROM af_workspace\nWHERE workspace_id = p_workspace_id\n  AND owner_uid = p_uid;\n-- Check if the insert operation was successful. If not, user is not the owner of the workspace.\nIF NOT FOUND THEN RAISE EXCEPTION 'Unsupported operation: User is not the owner of the workspace.';\nEND IF;\nEND;\n$$ LANGUAGE plpgsql;\n"
  },
  {
    "path": "migrations/20230906101555_user_profile.sql",
    "content": "-- af_user_profile_view is a view that contains all the user profiles and their latest workspace_id.\n-- a subquery is first used to find the workspace_id of the workspace with the latest updated_at timestamp for each\n-- user. This subquery is then joined with the af_user table to create the view. Note that a LEFT JOIN is used in\n-- case there are users without workspaces, in which case latest_workspace_id will be NULL for those users.\nCREATE OR REPLACE VIEW af_user_profile_view AS\nSELECT u.*,\n    w.workspace_id AS latest_workspace_id\nFROM af_user u\n    INNER JOIN (\n        SELECT uid,\n            workspace_id,\n            rank() OVER (\n                PARTITION BY uid\n                ORDER BY updated_at DESC\n            ) AS rn\n        FROM af_workspace_member\n    ) w ON u.uid = w.uid\n    AND w.rn = 1;"
  },
  {
    "path": "migrations/20230906102652_collab.sql",
    "content": "-- collab update table.\nCREATE TABLE IF NOT EXISTS af_collab (\n    oid TEXT NOT NULL,\n    blob BYTEA NOT NULL,\n    len INTEGER,\n    partition_key INTEGER NOT NULL,\n    encrypt INTEGER DEFAULT 0,\n    owner_uid BIGINT NOT NULL,\n    deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    workspace_id UUID NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    PRIMARY KEY (oid, partition_key)\n) PARTITION BY LIST (partition_key);\nCREATE TABLE IF NOT EXISTS af_collab_document PARTITION OF af_collab FOR\nVALUES IN (0);\nCREATE TABLE IF NOT EXISTS af_collab_database PARTITION OF af_collab FOR\nVALUES IN (1);\nCREATE TABLE IF NOT EXISTS af_collab_w_database PARTITION OF af_collab FOR\nVALUES IN (2);\nCREATE TABLE IF NOT EXISTS af_collab_folder PARTITION OF af_collab FOR\nVALUES IN (3);\nCREATE TABLE IF NOT EXISTS af_collab_database_row PARTITION OF af_collab FOR\nVALUES IN (4);\nCREATE TABLE IF NOT EXISTS af_collab_user_awareness PARTITION OF af_collab FOR\nVALUES IN (5);\n\nCREATE TABLE IF NOT EXISTS af_collab_member (\n    uid BIGINT REFERENCES af_user(uid) ON DELETE CASCADE,\n    oid TEXT NOT NULL,\n    permission_id INTEGER REFERENCES af_permissions(id) NOT NULL,\n    PRIMARY KEY(uid, oid)\n);\n\n-- Listener\nCREATE OR REPLACE FUNCTION notify_af_collab_member_change() RETURNS trigger AS $$\nDECLARE\npayload TEXT;\nBEGIN\n    payload := json_build_object(\n            'old', row_to_json(OLD),\n            'new', row_to_json(NEW),\n            'action_type', TG_OP\n            )::text;\n\n    PERFORM pg_notify('af_collab_member_channel', payload);\n    -- Return the new row state for INSERT/UPDATE, and the old state for DELETE.\n    IF TG_OP = 'DELETE' THEN\n        RETURN OLD;\nELSE\n        RETURN NEW;\nEND IF;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE TRIGGER af_collab_member_change_trigger\n    AFTER INSERT OR UPDATE OR DELETE ON af_collab_member\n    FOR EACH ROW EXECUTE FUNCTION notify_af_collab_member_change();\n\n-- collab snapshot. It will be used to store the snapshots of the collab.\nCREATE TABLE IF NOT EXISTS af_collab_snapshot (\n    sid BIGSERIAL PRIMARY KEY,-- snapshot id\n    oid TEXT NOT NULL,\n    blob BYTEA NOT NULL,\n    len INTEGER NOT NULL,\n    encrypt INTEGER DEFAULT 0,\n    deleted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,\n    workspace_id UUID NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_af_collab_snapshot_oid ON af_collab_snapshot(oid);\n"
  },
  {
    "path": "migrations/20230926145155_blob_storage.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_blob_metadata (\n    workspace_id UUID REFERENCES af_workspace(workspace_id) ON DELETE CASCADE NOT NULL,\n    file_id VARCHAR NOT NULL,\n    file_type VARCHAR NOT NULL,\n    file_size BIGINT NOT NULL,\n    modified_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    UNIQUE (workspace_id, file_id)\n);\n"
  },
  {
    "path": "migrations/20231113074418_user_change.sql",
    "content": "-- Add migration script here\n-- Drop the existing trigger if it exists\nDROP TRIGGER IF EXISTS af_user_change_trigger ON af_user;\n\n-- Create or replace the function\nCREATE OR REPLACE FUNCTION notify_af_user_change() RETURNS TRIGGER AS $$\nDECLARE\n    payload TEXT;\nBEGIN\n    payload := json_build_object(\n            'payload', row_to_json(NEW),\n            'action_type', TG_OP\n            )::text;\n\n    PERFORM pg_notify('af_user_channel', payload);\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Create the trigger\nCREATE TRIGGER af_user_change_trigger\n    AFTER UPDATE ON af_user\n    FOR EACH ROW\nEXECUTE FUNCTION notify_af_user_change();\n\n"
  },
  {
    "path": "migrations/20231130150001_user_id_foreign_key.sql",
    "content": "-- Add foreign key constraint to public.af_user table\n-- Edited ON 2025-05-04 in order to prevent supbase_auth_admin role from being mandatory.\n-- Rewritten with exception handling such that existing database won't throw an error WHEN\n-- this migration is re-applied. Not dropping and recreating the constraint as it is potentially\n-- expensive with large number of users.\nDO $$\nBEGIN\n  BEGIN\n    ALTER TABLE public.af_user ADD CONSTRAINT af_user_email_foreign_key FOREIGN KEY (uuid) REFERENCES auth.users (id) ON DELETE CASCADE;\n  EXCEPTION\n    WHEN duplicate_object THEN\n      NULL;\n    WHEN OTHERS THEN\n      RAISE;\n  END;\nEND $$;\n"
  },
  {
    "path": "migrations/20240123140707_workspace_owner_trigger.sql",
    "content": "CREATE OR REPLACE FUNCTION af_workspace_insert_trigger()\nRETURNS TRIGGER AS $$\nBEGIN\n    -- Insert a record into af_workspace_member\n    INSERT INTO public.af_workspace_member (\n\tuid, role_id,\n\tworkspace_id, created_at, updated_at)\n    VALUES (\n\tNEW.owner_uid, (SELECT id FROM public.af_roles WHERE name = 'Owner'),\n\tNEW.workspace_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);\n\n    -- Return the new record to complete the insert operation\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER af_workspace_after_insert\nAFTER INSERT ON public.af_workspace\nFOR EACH ROW\nEXECUTE FUNCTION af_workspace_insert_trigger();\n"
  },
  {
    "path": "migrations/20240227000000_workspace_icon.sql",
    "content": "ALTER TABLE af_workspace ADD COLUMN icon TEXT NOT NULL DEFAULT '';\n"
  },
  {
    "path": "migrations/20240303003711_collab_member_timestamp.sql",
    "content": "-- Add migration script here\nALTER TABLE af_collab_member\nADD COLUMN created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW());"
  },
  {
    "path": "migrations/20240304173938_workspace_invitation.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_workspace_invitation (\n    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),\n    workspace_id UUID NOT NULL,\n    inviter BIGINT NOT NULL,\n    invitee BIGINT NOT NULL,\n    role_id INT NOT NULL,\n    status SMALLINT NOT NULL DEFAULT 0, -- 0: pending, 1: accepted, 2: rejected\n\n    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL\n);\nCREATE INDEX idx_af_workspace_invitation_inviter ON af_workspace_invitation (inviter);\nCREATE INDEX idx_af_workspace_invitation_invitee ON af_workspace_invitation (invitee);\n\n-- Auto update updated_at column upon status change\nCREATE OR REPLACE FUNCTION update_af_workspace_invitation_updated_at_column()\nRETURNS TRIGGER AS $$\nBEGIN\n    IF OLD.status IS DISTINCT FROM NEW.status THEN\n        NEW.updated_at = CURRENT_TIMESTAMP;\n    END IF;\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER af_workspace_invitation_status_update\nBEFORE UPDATE ON af_workspace_invitation\nFOR EACH ROW\nWHEN (OLD.status IS DISTINCT FROM NEW.status)\nEXECUTE FUNCTION update_af_workspace_invitation_updated_at_column();\n\n-- Auto add to af_workspace_member upon invitation accepted\nCREATE OR REPLACE FUNCTION add_to_af_workspace_member()\nRETURNS TRIGGER AS $$\nBEGIN\n  IF NEW.status = 1 THEN\n    -- workspace permission\n    INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n    VALUES (NEW.workspace_id, NEW.invitee, NEW.role_id)\n    ON CONFLICT (workspace_id, uid) DO NOTHING;\n\n    -- collab permission\n    INSERT INTO af_collab_member (uid, oid, permission_id)\n    VALUES (\n      NEW.invitee,\n      NEW.workspace_id,\n      (SELECT permission_id\n       FROM public.af_role_permissions\n       WHERE public.af_role_permissions.role_id = NEW.role_id)\n    )\n    ON CONFLICT (uid, oid)\n    DO UPDATE\n      SET permission_id = excluded.permission_id;\n  END IF;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER af_workspace_invitation_accepted\nBEFORE UPDATE ON af_workspace_invitation\nFOR EACH ROW\nWHEN (OLD.status IS DISTINCT FROM NEW.status AND NEW.status = 1)\nEXECUTE FUNCTION add_to_af_workspace_member();\n"
  },
  {
    "path": "migrations/20240306110000_workspace_invitation_2.sql",
    "content": "ALTER TABLE af_workspace_invitation ADD COLUMN invitee_email TEXT NOT NULL;\n\n-- Auto add to af_workspace_member upon invitation accepted\nCREATE OR REPLACE FUNCTION add_to_af_workspace_member()\nRETURNS TRIGGER AS $$\nBEGIN\n  IF NEW.status = 1 THEN\n    -- workspace permission\n    INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n    VALUES (\n      NEW.workspace_id,\n      (SELECT uid FROM af_user WHERE email = NEW.invitee_email),\n      NEW.role_id\n    )\n    ON CONFLICT (workspace_id, uid) DO NOTHING;\n\n    -- collab permission\n    INSERT INTO af_collab_member (uid, oid, permission_id)\n    VALUES (\n      (SELECT uid FROM af_user WHERE email = NEW.invitee_email),\n      NEW.workspace_id,\n      (SELECT permission_id\n       FROM public.af_role_permissions\n       WHERE public.af_role_permissions.role_id = NEW.role_id)\n    )\n    ON CONFLICT (uid, oid)\n    DO UPDATE\n      SET permission_id = excluded.permission_id;\n  END IF;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nALTER TABLE af_workspace_invitation DROP COLUMN invitee;\n"
  },
  {
    "path": "migrations/20240412083446_history_init.sql",
    "content": "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";\n\n-- create af_snapshot_meta table\nCREATE TABLE IF NOT EXISTS af_snapshot_meta(\n    oid TEXT NOT NULL,\n    workspace_id UUID NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    snapshot BYTEA NOT NULL,\n    snapshot_version INTEGER NOT NULL,\n    partition_key INTEGER NOT NULL,\n    created_at BIGINT NOT NULL,\n    metadata JSONB,\n    PRIMARY KEY (oid, created_at, partition_key)\n) PARTITION BY LIST (partition_key);\nCREATE TABLE af_snapshot_meta_document PARTITION OF af_snapshot_meta FOR\nVALUES IN (0);\nCREATE TABLE af_snapshot_meta_database PARTITION OF af_snapshot_meta FOR\nVALUES IN (1);\nCREATE TABLE af_snapshot_meta_workspace_database PARTITION OF af_snapshot_meta FOR\nVALUES IN (2);\nCREATE TABLE af_snapshot_meta_folder PARTITION OF af_snapshot_meta FOR\nVALUES IN (3);\nCREATE TABLE af_snapshot_meta_database_row PARTITION OF af_snapshot_meta FOR\nVALUES IN (4);\nCREATE TABLE af_snapshot_meta_user_awareness PARTITION OF af_snapshot_meta FOR\nVALUES IN (5);\n\n-- create af_snapshot_state table\nCREATE TABLE IF NOT EXISTS af_snapshot_state(\n    snapshot_id UUID NOT NULL DEFAULT uuid_generate_v4(),\n    workspace_id UUID NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    oid TEXT NOT NULL,\n    doc_state BYTEA NOT NULL,\n    doc_state_version INTEGER NOT NULL,\n    deps_snapshot_id UUID,\n    partition_key INTEGER NOT NULL,\n    created_at BIGINT NOT NULL,\n    PRIMARY KEY (snapshot_id, partition_key)\n) PARTITION BY LIST (partition_key);\nCREATE TABLE af_snapshot_state_document PARTITION OF af_snapshot_state FOR\nVALUES IN (0);\nCREATE TABLE af_snapshot_state_database PARTITION OF af_snapshot_state FOR\nVALUES IN (1);\nCREATE TABLE af_snapshot_state_workspace_database PARTITION OF af_snapshot_state FOR\nVALUES IN (2);\nCREATE TABLE af_snapshot_state_folder PARTITION OF af_snapshot_state FOR\nVALUES IN (3);\nCREATE TABLE af_snapshot_state_database_row PARTITION OF af_snapshot_state FOR\nVALUES IN (4);\nCREATE TABLE af_snapshot_state_user_awareness PARTITION OF af_snapshot_state FOR\nVALUES IN (5);\n\n-- Index for af_snapshot_state table\nCREATE INDEX IF NOT EXISTS idx_snapshot_state_oid_created ON af_snapshot_state (oid, created_at DESC);\n"
  },
  {
    "path": "migrations/20240510024506_chat_message.sql",
    "content": "-- Add migration script here\n-- Create table for chat documents\nCREATE TABLE af_chat\n(\n    chat_id      UUID PRIMARY KEY,\n    created_at   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    deleted_at   TIMESTAMP WITH TIME ZONE          DEFAULT NULL,\n    name         TEXT                     NOT NULL DEFAULT '',\n    rag_ids      JSONB                    NOT NULL DEFAULT '[]',\n    workspace_id UUID                     NOT NULL,\n    FOREIGN KEY (workspace_id) REFERENCES af_workspace (workspace_id) ON DELETE CASCADE\n);\n\n-- Create table for chat messages\nCREATE TABLE af_chat_messages\n(\n    message_id BIGSERIAL PRIMARY KEY,\n    author     JSONB                    NOT NULL,\n    chat_id    UUID                     NOT NULL,\n    content    TEXT                     NOT NULL,\n    deleted_at TIMESTAMP WITH TIME ZONE          DEFAULT NULL,\n    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n    edited_at  TIMESTAMP                         DEFAULT NULL,\n    FOREIGN KEY (chat_id) REFERENCES af_chat (chat_id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_chat_messages_chat_id_created_at ON af_chat_messages (message_id ASC, created_at ASC);\n"
  },
  {
    "path": "migrations/20240529054858_workspace_add_token_usage.sql",
    "content": "-- Add migration script here\nALTER TABLE af_workspace ADD COLUMN search_token_usage BIGINT NOT NULL DEFAULT 0;\nALTER TABLE af_workspace ADD COLUMN index_token_usage BIGINT NOT NULL DEFAULT 0;"
  },
  {
    "path": "migrations/20240531031836_chat_message_meta.sql",
    "content": "-- Add migration script here\nALTER TABLE af_chat\n    ADD COLUMN meta_data JSONB DEFAULT '{}' NOT NULL;\n\nALTER TABLE af_chat_messages\n    ADD COLUMN meta_data JSONB DEFAULT '{}' NOT NULL,\n    ADD COLUMN reply_message_id BIGINT;\n"
  },
  {
    "path": "migrations/20240604090043_add_workspace_settings.sql",
    "content": "-- Add migration script here\nALTER TABLE af_workspace ADD COLUMN settings JSONB;"
  },
  {
    "path": "migrations/20240613112820_publish_collab.sql",
    "content": "-- stores the published view of a workspace by a user of workspace\nCREATE TABLE IF NOT EXISTS af_published_collab (\n    doc_name     TEXT   NOT NULL,\n    published_by BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE,\n    workspace_id UUID   NOT NULL REFERENCES af_workspace(workspace_id) ON DELETE CASCADE,\n    metadata     JSONB  NOT NULL,\n    blob         BYTEA  NOT NULL DEFAULT '',\n    created_at   TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    updated_at   TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n\n    PRIMARY KEY (workspace_id, doc_name)\n);\n\n-- trigger to update updated_at column\nCREATE OR REPLACE FUNCTION update_updated_at()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = CURRENT_TIMESTAMP;\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE TRIGGER af_published_collab_update_updated_at\nBEFORE UPDATE ON af_published_collab\nFOR EACH ROW\nEXECUTE FUNCTION update_updated_at();\n\n-- every workspace have a prefix for published view\nALTER TABLE af_workspace ADD COLUMN publish_namespace TEXT UNIQUE;\nCREATE INDEX IF NOT EXISTS publish_namespace_idx ON af_workspace(publish_namespace);\n"
  },
  {
    "path": "migrations/20240614171931_collab_embeddings.sql",
    "content": "DO $$\nBEGIN\n    -- Add migration script here\n    CREATE EXTENSION IF NOT EXISTS vector;\n\n    -- create table to store collab embeddings\n    CREATE TABLE IF NOT EXISTS af_collab_embeddings\n    (\n        fragment_id TEXT NOT NULL PRIMARY KEY,\n        oid TEXT NOT NULL,\n        partition_key INTEGER NOT NULL,\n        content_type INTEGER NOT NULL,\n        indexed_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT (NOW()),\n        content TEXT,\n        embedding VECTOR(1536),\n        FOREIGN KEY (oid, partition_key) REFERENCES af_collab (oid, partition_key) ON DELETE CASCADE\n    );\n\n    CREATE INDEX IF NOT EXISTS af_collab_embeddings_similarity_idx ON af_collab_embeddings USING hnsw (embedding vector_cosine_ops);\n\nEXCEPTION WHEN OTHERS THEN\n    RAISE NOTICE 'could not create \"vector\" extension, ignoring this migration';\nEND;\n$$ LANGUAGE plpgsql;"
  },
  {
    "path": "migrations/20240617135926_af_workspace_foreign_key_indices.sql",
    "content": "CREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_blob_metadata          ON af_blob_metadata          (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_chat                   ON af_chat                   (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_collab_snapshot        ON af_collab_snapshot        (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_collab                 ON af_collab                 (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_snapshot_meta          ON af_snapshot_meta          (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_snapshot_state         ON af_snapshot_state         (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_workspace_member       ON af_workspace_member       (workspace_id);\n"
  },
  {
    "path": "migrations/20240618035048_af_workspace_ai_usage.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_workspace_ai_usage (\n    created_at DATE NOT NULL,               -- day level of granularity\n    workspace_id UUID NOT NULL,             -- workspace id for which the usage is being recorded\n    search_requests INT,                    -- number of search requests made\n    search_tokens_consumed BIGINT,          -- number of tokens consumed for search requests\n    index_tokens_consumed BIGINT,           -- number of tokens consumed for indexing documents\n    PRIMARY KEY (created_at, workspace_id)\n);\n\n-- migrate token usage data from af_workspace to af_workspace_ai_usage\nINSERT INTO af_workspace_ai_usage (created_at, workspace_id, search_tokens_consumed, index_tokens_consumed)\nSELECT\n    now()::date as created_at,\n    workspace_id,\n    search_token_usage as search_tokens_consumed,\n    index_token_usage as index_tokens_consumed\nFROM af_workspace\nWHERE search_token_usage IS NOT NULL\n   OR index_token_usage IS NOT NULL;\n\n-- drop the redundant columns from af_workspace\nALTER TABLE af_workspace DROP COLUMN IF EXISTS search_token_usage;\nALTER TABLE af_workspace DROP COLUMN IF EXISTS index_token_usage;"
  },
  {
    "path": "migrations/20240618173348_publish_collab_2.sql",
    "content": "ALTER TABLE af_published_collab ADD COLUMN view_id UUID NOT NULL DEFAULT gen_random_uuid();\nALTER TABLE af_published_collab DROP CONSTRAINT af_published_collab_pkey;\nALTER TABLE af_published_collab ADD PRIMARY KEY (workspace_id, view_id);\n\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_published_collab ON af_published_collab (workspace_id);\nCREATE INDEX IF NOT EXISTS idx_published_by_on_af_published_collab ON af_published_collab (published_by);\n"
  },
  {
    "path": "migrations/20240621105148_publish_collab_3.sql",
    "content": "ALTER TABLE af_published_collab RENAME COLUMN doc_name TO publish_name;\n"
  },
  {
    "path": "migrations/20240626184736_publish_collab_4.sql",
    "content": "-- Add a unique constraint on publish_name\nALTER TABLE public.af_published_collab\nADD CONSTRAINT unique_publish_name UNIQUE (publish_name);\n\n-- Add an index on publish_name\nCREATE INDEX idx_publish_name ON public.af_published_collab (publish_name);\n"
  },
  {
    "path": "migrations/20240627525836_publish_collab_5.sql",
    "content": "-- Drop the existing unique constraint on publish_name\nALTER TABLE public.af_published_collab\nDROP CONSTRAINT unique_publish_name;\n\n-- Add a new unique constraint for the combination of publish_name and workspace_id\nALTER TABLE public.af_published_collab\nADD CONSTRAINT unique_workspace_id_publish_name UNIQUE (workspace_id, publish_name);\n"
  },
  {
    "path": "migrations/20240629035230_publish_collab_6.sql",
    "content": "-- Update existing null values to ensure no nulls are present before adding NOT NULL constraint\nUPDATE public.af_workspace\nSET publish_namespace = uuid_generate_v4()::text\nWHERE publish_namespace IS NULL;\n\n-- Alter the column to set NOT NULL constraint and a default value\nALTER TABLE public.af_workspace\nALTER COLUMN publish_namespace SET NOT NULL,\nALTER COLUMN publish_namespace SET DEFAULT uuid_generate_v4()::text;\n"
  },
  {
    "path": "migrations/20240630010030_workspace_member_foreign_key.sql",
    "content": "ALTER TABLE public.af_workspace_member\nADD CONSTRAINT af_workspace_member_uid_fkey\nFOREIGN KEY (uid) REFERENCES af_user(uid) ON DELETE CASCADE;\n"
  },
  {
    "path": "migrations/20240723090305_publish_view_comment.sql",
    "content": "-- stores the comments on a published view\nCREATE TABLE IF NOT EXISTS af_published_view_comment (\n  comment_id          UUID NOT NULL DEFAULT gen_random_uuid(),\n  -- comments are never deleted, only marked as deleted, unless we intentionally wants to clean\n  -- the tables by removing the comments from the database\n  reply_comment_id    UUID REFERENCES af_published_view_comment(comment_id) ON DELETE CASCADE,\n  -- The view id should exists on af_published_collab, However, we can't enforce this foreign key\n  -- constraint because af_published_collab primary key is (workspace_id, view_id).\n  -- We also have the requirement to keep the comments even if the view is unpublished.\n  view_id             UUID NOT NULL,\n  content             TEXT NOT NULL,\n  -- preserve comment when user is removed\n  created_by          BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE SET NULL,\n  created_at          TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at          TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  is_deleted          BOOLEAN NOT NULL DEFAULT FALSE,\n\n  PRIMARY KEY         (comment_id)\n);\nCREATE INDEX IF NOT EXISTS idx_view_id_on_af_published_view_comment ON af_published_view_comment(view_id);\n"
  },
  {
    "path": "migrations/20240725065111_publish_view_reaction.sql",
    "content": "-- stores the reactions on a published view\nCREATE TABLE IF NOT EXISTS af_published_view_reaction (\n  comment_id          UUID NOT NULL REFERENCES af_published_view_comment(comment_id) ON DELETE CASCADE,\n  reaction_type       TEXT NOT NULL,\n  created_by          BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE,\n  view_id             UUID NOT NULL,\n  PRIMARY KEY         (comment_id, reaction_type, created_by)\n);\nCREATE INDEX IF NOT EXISTS idx_view_id_on_af_published_view_reaction ON af_published_view_reaction(view_id);\n"
  },
  {
    "path": "migrations/20240729065107_publish_view_reaction_2.sql",
    "content": "ALTER TABLE af_published_view_reaction ADD COLUMN created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP;\n"
  },
  {
    "path": "migrations/20240806054557_template_category.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_template_category (\n  category_id   UUID NOT NULL DEFAULT gen_random_uuid(),\n  created_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  name          TEXT NOT NULL,\n  icon          TEXT NOT NULL,\n  description   TEXT NOT NULL,\n  bg_color      TEXT NOT NULL,\n  category_type INT NOT NULL,\n  priority      INT NOT NULL,\n\n  PRIMARY KEY (category_id)\n);\n"
  },
  {
    "path": "migrations/20240806103039_template_creator.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_template_creator (\n  creator_id        UUID NOT NULL DEFAULT gen_random_uuid(),\n  created_at        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  name              TEXT NOT NULL,\n  avatar_url        TEXT NOT NULL,\n\n  PRIMARY KEY (creator_id)\n);\n\nCREATE TABLE IF NOT EXISTS af_template_creator_account_link (\n  creator_id        UUID NOT NULL REFERENCES af_template_creator(creator_id) ON DELETE CASCADE,\n  link_type         TEXT NOT NULL,\n  url               TEXT NOT NULL,\n\n  PRIMARY KEY (creator_id, link_type)\n);\n"
  },
  {
    "path": "migrations/20240813040905_template.sql",
    "content": "-- Appflowy template is based on a published view. Template information should be preserved even if the\n-- published view is deleted.\nCREATE TABLE IF NOT EXISTS af_template_view (\n  view_id           UUID NOT NULL,\n  created_at        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  name              TEXT NOT NULL,\n  description       TEXT NOT NULL,\n  about             TEXT NOT NULL,\n  view_url          TEXT NOT NULL,\n  creator_id        UUID NOT NULL REFERENCES af_template_creator(creator_id) ON DELETE CASCADE,\n  is_new_template   BOOLEAN NOT NULL,\n  is_featured       BOOLEAN NOT NULL,\n\n  PRIMARY KEY (view_id)\n);\n\nCREATE TABLE IF NOT EXISTS af_template_view_template_category (\n  view_id       UUID NOT NULL REFERENCES af_template_view(view_id) ON DELETE CASCADE,\n  category_id   UUID NOT NULL REFERENCES af_template_category(category_id) ON DELETE CASCADE,\n\n  PRIMARY KEY (view_id, category_id)\n);\n\nCREATE TABLE IF NOT EXISTS af_related_template_view (\n  view_id           UUID NOT NULL REFERENCES af_template_view(view_id) ON DELETE CASCADE,\n  related_view_id   UUID NOT NULL REFERENCES af_template_view(view_id) ON DELETE CASCADE,\n\n  PRIMARY KEY (view_id, related_view_id)\n);\n\nBEGIN;\n\nDO $$\nBEGIN\n  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'account_link_type') THEN\n    CREATE TYPE account_link_type AS (\n        link_type    TEXT,\n        url          TEXT\n    );\n  END IF;\n\n  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_creator_type') THEN\n    CREATE TYPE template_creator_type AS (\n        creator_id          UUID,\n        name                TEXT,\n        avatar_url          TEXT,\n        account_links       account_link_type[],\n        number_of_templates INT\n    );\n  END IF;\n\n  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_creator_minimal_type') THEN\n    CREATE TYPE template_creator_minimal_type AS (\n        creator_id    UUID,\n        name          TEXT,\n        avatar_url    TEXT\n    );\n  END IF;\n\n  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_category_minimal_type') THEN\n    CREATE TYPE template_category_minimal_type AS (\n      category_id   UUID,\n      name          TEXT,\n      icon          TEXT,\n      bg_color      TEXT\n    );\n  END IF;\n\n  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_minimal_type') THEN\n    CREATE TYPE template_minimal_type AS (\n      view_id         UUID,\n      created_at      TIMESTAMP WITH TIME ZONE,\n      updated_at      TIMESTAMP WITH TIME ZONE,\n      name            TEXT,\n      description     TEXT,\n      view_url        TEXT,\n      creator         template_creator_minimal_type,\n      categories      template_category_minimal_type[],\n      is_new_template BOOLEAN,\n      is_featured     BOOLEAN\n    );\n  END IF;\nEND\n$$;\n"
  },
  {
    "path": "migrations/20240910100000_af_collab_embeddings_indices.sql",
    "content": "DO $$\nBEGIN\n    CREATE INDEX IF NOT EXISTS af_collab_embeddings_oid_idx ON public.af_collab_embeddings (oid);\nEXCEPTION WHEN others THEN\n    RAISE NOTICE 'could not create index on af_collab_embeddings(oid), ignoring this migration';\nEND $$;\n"
  },
  {
    "path": "migrations/20240924045045_access_request.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_access_request (\n  request_id UUID NOT NULL DEFAULT gen_random_uuid(),\n  uid BIGINT NOT NULL REFERENCES af_user(uid) ON DELETE CASCADE,\n  workspace_id UUID NOT NULL,\n  view_id UUID NOT NULL,\n  status INT NOT NULL,\n  created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  UNIQUE (uid, workspace_id, view_id),\n  PRIMARY KEY(request_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_access_request ON af_access_request(workspace_id);\nCREATE INDEX IF NOT EXISTS idx_uid_on_af_access_request ON af_access_request(uid);\n"
  },
  {
    "path": "migrations/20240930135712_import_data.sql",
    "content": "-- Add migration script here\nCREATE TABLE af_import_task(\n    task_id UUID NOT NULL PRIMARY KEY,\n    file_size BIGINT NOT NULL,          -- File size in bytes, BIGINT for large files\n    workspace_id TEXT NOT NULL,         -- Workspace id\n    created_by BIGINT NOT NULL,         -- User ID\n    status SMALLINT NOT NULL,           -- Status of the file import (e.g., 0 for pending, 1 for completed, 2 for failed)\n    metadata JSONB DEFAULT '{}' NOT NULL,\n    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP\n);\n\nCREATE INDEX idx_af_import_task_status_created_at\nON af_import_task (status, created_at);\n\n-- For existing workspaces, this column will be NULL. So Null and true will be considered as\n-- initialized and false will be considered as not initialized.\nALTER TABLE af_workspace\nADD COLUMN is_initialized BOOLEAN DEFAULT NULL;\n\nCREATE INDEX idx_af_workspace_is_initialized\nON af_workspace (is_initialized);"
  },
  {
    "path": "migrations/20241014153023_default_published_view.sql",
    "content": "ALTER TABLE af_workspace ADD COLUMN default_published_view_id UUID;\n\nALTER TABLE af_published_collab ALTER COLUMN created_at SET NOT NULL;\nALTER TABLE af_published_collab ALTER COLUMN updated_at SET NOT NULL;\n"
  },
  {
    "path": "migrations/20241025135939_import_task_add_uid_column.sql",
    "content": "-- Add migration script here\nALTER TABLE af_import_task\nADD COLUMN uid BIGINT,\nADD COLUMN file_url TEXT;\n\n-- Update the existing index to include the new uid column\nDROP INDEX IF EXISTS idx_af_import_task_status_created_at;\n\nCREATE INDEX idx_af_import_task_uid_status_created_at\nON af_import_task (uid, status, created_at);\n"
  },
  {
    "path": "migrations/20241031094508_af_uuid_indexes.sql",
    "content": "CREATE UNIQUE INDEX IF NOT EXISTS uq_af_user_uuid\n    ON af_user (uuid)\n    INCLUDE (uid);\n\n"
  },
  {
    "path": "migrations/20241101063559_af_workspace_namespace.sql",
    "content": "-- We will no longer use `publish_namespace` column to store user defined namespace.\n-- `publish_namespace` will only be used to as fallback/default namespace if user did not set custom namespace.\n-- `publish_namespace` will be initialized as random UUID and will never be modified.\n-- We will remove UNIQUE constraint on `publish_namespace` column to avoid performance penalty on insert.\n-- We will use a new table `af_workspace_namespace` to store user defined namespace for workspace.\nALTER TABLE af_workspace DROP CONSTRAINT af_workspace_publish_namespace_key;\n\n-- Table to store user defined namespace for workspace\nCREATE TABLE IF NOT EXISTS af_workspace_namespace (\n  namespace    TEXT NOT NULL PRIMARY KEY,\n  workspace_id UUID NOT NULL,\n  is_original  BOOLEAN NOT NULL,\n  created_at   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n  updated_at   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,\n\n  FOREIGN KEY (workspace_id) REFERENCES af_workspace (workspace_id) ON DELETE CASCADE\n);\n\n-- Create index to ensure fast lookup by workspace_id\nCREATE INDEX idx_af_workspace_namespace_workspace_id ON af_workspace_namespace (workspace_id);\n\n-- Create a partial unique index to enforce that only one original namespace exists per workspace\nCREATE UNIQUE INDEX ON af_workspace_namespace (workspace_id)\nWHERE is_original = TRUE;\n\n-- Create a function to update the updated_at column\nCREATE OR REPLACE FUNCTION update_updated_at_column()\nRETURNS TRIGGER AS $$\nBEGIN\n  NEW.updated_at = CURRENT_TIMESTAMP;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Create a trigger to call the function before each update\nCREATE TRIGGER trigger_update_updated_at\nBEFORE UPDATE ON af_workspace_namespace\nFOR EACH ROW\nEXECUTE FUNCTION update_updated_at_column();\n\n-- Create a trigger that will create a row in `af_workspace_namespace` when a record is inserted in `af_workspace`\nCREATE OR REPLACE FUNCTION create_workspace_namespace()\nRETURNS TRIGGER AS $$\nBEGIN\n  INSERT INTO af_workspace_namespace (namespace, workspace_id, is_original)\n  VALUES (uuid_generate_v4()::text, NEW.workspace_id, TRUE);\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\nCREATE TRIGGER trigger_create_workspace_namespace\nAFTER INSERT ON af_workspace\nFOR EACH ROW\nEXECUTE FUNCTION create_workspace_namespace();\n\n-- Insert existing workspace records into `af_workspace_namespace`\nINSERT INTO af_workspace_namespace (namespace, workspace_id, is_original)\nSELECT publish_namespace, workspace_id, TRUE\nFROM af_workspace\nON CONFLICT (namespace) DO NOTHING; -- if there happens to be a workspace creation during migration\n\n-- Drop existing `publish_namespace` column for workspace_id\nALTER TABLE af_workspace DROP COLUMN publish_namespace;\n"
  },
  {
    "path": "migrations/20241108155841_unpublished_collab.sql",
    "content": "ALTER TABLE af_published_collab ADD COLUMN unpublished_at timestamp with time zone;\n"
  },
  {
    "path": "migrations/20241124212630_af_collab_updated_at.sql",
    "content": "-- Add `updated_at` column to `af_collab` table\nALTER TABLE public.af_collab\nADD COLUMN updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP;\n\n-- Create or replace function to update `updated_at` column\nCREATE OR REPLACE FUNCTION update_updated_at_column()\nRETURNS TRIGGER AS $$\nBEGIN\n    NEW.updated_at = CURRENT_TIMESTAMP;\n    RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\n-- Create trigger to update `updated_at` column\nCREATE TRIGGER set_updated_at\nBEFORE INSERT OR UPDATE ON public.af_collab\nFOR EACH ROW\nEXECUTE FUNCTION update_updated_at_column();\n\n-- Create index on `updated_at` column\nCREATE INDEX idx_af_collab_updated_at\nON public.af_collab (updated_at);\n"
  },
  {
    "path": "migrations/20241126175909_af_collab_stored_procedures.sql",
    "content": "CREATE OR REPLACE PROCEDURE af_collab_upsert(\n    IN p_workspace_id UUID,\n    IN p_oid TEXT,\n    IN p_partition_key INT,\n    IN p_uid BIGINT,\n    IN p_encrypt INT,\n    IN p_blob BYTEA\n)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    INSERT INTO af_collab (oid, blob, len, partition_key, encrypt, owner_uid, workspace_id)\n    VALUES (p_oid, p_blob, LENGTH(p_blob), p_partition_key, p_encrypt, p_uid, p_workspace_id)\n    ON CONFLICT (oid, partition_key)\n    DO UPDATE SET blob = p_blob, len = LENGTH(p_blob), encrypt = p_encrypt, owner_uid = p_uid WHERE excluded.workspace_id = af_collab.workspace_id;\n\n    INSERT INTO af_collab_member (uid, oid, permission_id)\n    SELECT p_uid, p_oid, rp.permission_id\n    FROM af_role_permissions rp\n    JOIN af_roles ON rp.role_id = af_roles.id\n    WHERE af_roles.name = 'Owner'\n    ON CONFLICT (uid, oid)\n    DO UPDATE SET permission_id = excluded.permission_id;\nEND\n$$;\n\nCREATE TYPE af_fragment AS (fragment_id TEXT, content_type INT, contents TEXT, embedding VECTOR(1536));\n\nCREATE OR REPLACE PROCEDURE af_collab_embeddings_upsert(\n    IN p_workspace_id UUID,\n    IN p_oid TEXT,\n    IN p_partition_key INT,\n    IN p_tokens_used INT,\n    IN p_fragments af_fragment[]\n)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    DELETE FROM af_collab_embeddings WHERE oid = p_oid;\n\n    INSERT INTO af_collab_embeddings (fragment_id, oid, partition_key, content_type, content, embedding, indexed_at)\n    SELECT f.fragment_id, p_oid, p_partition_key, f.content_type, f.contents, f.embedding, NOW()\n    FROM UNNEST(p_fragments) as f;\n\n    INSERT INTO af_workspace_ai_usage(created_at, workspace_id, search_requests, search_tokens_consumed, index_tokens_consumed)\n    VALUES (now()::date, p_workspace_id, 0, 0, p_tokens_used)\n    ON CONFLICT (created_at, workspace_id)\n    DO UPDATE SET index_tokens_consumed = af_workspace_ai_usage.index_tokens_consumed + p_tokens_used;\nEND\n$$;"
  },
  {
    "path": "migrations/20241211034455_stop_writing_to_collab_member.sql",
    "content": "DROP PROCEDURE IF EXISTS af_collab_upsert\n"
  },
  {
    "path": "migrations/20241216080018_quick_notes.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_quick_note (\n  quick_note_id UUID NOT NULL DEFAULT gen_random_uuid (),\n  workspace_id UUID NOT NULL,\n  uid BIGINT NOT NULL REFERENCES af_user (uid) ON DELETE CASCADE,\n  updated_at TIMESTAMP\n  WITH\n    TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    created_at TIMESTAMP\n  WITH\n    TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n    data JSONB NOT NULL,\n    PRIMARY KEY (quick_note_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_workspace_id_on_af_quick_note ON af_quick_note (workspace_id);\n\nCREATE INDEX IF NOT EXISTS idx_uid_on_af_quick_note ON af_quick_note (uid);\n"
  },
  {
    "path": "migrations/20241218090459_collab_embedding_add_metadata.sql",
    "content": "-- Add migration script here\nALTER TABLE af_collab_embeddings\nADD COLUMN metadata JSONB DEFAULT '{}'::jsonb;\n\nCREATE TYPE af_fragment_v2 AS (\n    fragment_id TEXT,\n    content_type INT,\n    contents TEXT,\n    embedding VECTOR(1536),\n    metadata JSONB\n);\n\nCREATE OR REPLACE PROCEDURE af_collab_embeddings_upsert(\n    IN p_workspace_id UUID,\n    IN p_oid TEXT,\n    IN p_partition_key INT,\n    IN p_tokens_used INT,\n    IN p_fragments af_fragment_v2[]\n)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    DELETE FROM af_collab_embeddings WHERE oid = p_oid;\n    INSERT INTO af_collab_embeddings (fragment_id, oid, partition_key, content_type, content, embedding, indexed_at, metadata)\n    SELECT\n        f.fragment_id,\n        p_oid,\n        p_partition_key,\n        f.content_type,\n        f.contents,\n        f.embedding,\n        NOW(),\n        f.metadata\n    FROM UNNEST(p_fragments) as f;\n\n    -- Update the usage tracking table\n    INSERT INTO af_workspace_ai_usage(created_at, workspace_id, search_requests, search_tokens_consumed, index_tokens_consumed)\n    VALUES (now()::date, p_workspace_id, 0, 0, p_tokens_used)\n    ON CONFLICT (created_at, workspace_id)\n    DO UPDATE SET index_tokens_consumed = af_workspace_ai_usage.index_tokens_consumed + p_tokens_used;\nEND\n$$;"
  },
  {
    "path": "migrations/20241222152427_collab_add_indexed_at.sql",
    "content": "-- Add migration script here\nALTER TABLE af_collab\nADD COLUMN indexed_at TIMESTAMP WITH TIME ZONE DEFAULT NULL;"
  },
  {
    "path": "migrations/20241230064618_collab_embedding_add_fragment_index.sql",
    "content": "-- Add migration script here\nALTER TABLE af_collab_embeddings\nADD COLUMN fragment_index INTEGER DEFAULT 0,\nADD COLUMN embedder_type SMALLINT DEFAULT 0;\n\nCREATE TYPE af_fragment_v3 AS (\n    fragment_id TEXT,\n    content_type INT,\n    contents TEXT,\n    embedding VECTOR(1536),\n    metadata JSONB,\n    fragment_index INTEGER,\n    embedder_type SMALLINT\n);\n\nCREATE OR REPLACE PROCEDURE af_collab_embeddings_upsert(\n    IN p_workspace_id UUID,\n    IN p_oid TEXT,\n    IN p_partition_key INT,\n    IN p_tokens_used INT,\n    IN p_fragments af_fragment_v3[]\n)\nLANGUAGE plpgsql\nAS $$\nBEGIN\n    DELETE FROM af_collab_embeddings WHERE oid = p_oid;\n    INSERT INTO af_collab_embeddings (fragment_id, oid, partition_key, content_type, content, embedding, indexed_at, metadata, fragment_index, embedder_type)\n    SELECT\n        f.fragment_id,\n        p_oid,\n        p_partition_key,\n        f.content_type,\n        f.contents,\n        f.embedding,\n        NOW(),\n        f.metadata,\n        f.fragment_index,\n        f.embedder_type\n    FROM UNNEST(p_fragments) as f;\n\n    -- Update the usage tracking table\n    INSERT INTO af_workspace_ai_usage(created_at, workspace_id, search_requests, search_tokens_consumed, index_tokens_consumed)\n    VALUES (now()::date, p_workspace_id, 0, 0, p_tokens_used)\n    ON CONFLICT (created_at, workspace_id)\n    DO UPDATE SET index_tokens_consumed = af_workspace_ai_usage.index_tokens_consumed + p_tokens_used;\nEND\n$$;"
  },
  {
    "path": "migrations/20250109142738_blob_metadata_add_file_status.sql",
    "content": "-- Add migration script here\nALTER TABLE af_blob_metadata\nADD COLUMN status SMALLINT NOT NULL DEFAULT 0;\n"
  },
  {
    "path": "migrations/20250113091708_publish_options.sql",
    "content": "ALTER TABLE af_published_collab\nADD COLUMN comments_enabled BOOLEAN NOT NULL DEFAULT TRUE,\nADD COLUMN duplicate_enabled BOOLEAN NOT NULL DEFAULT TRUE;\n"
  },
  {
    "path": "migrations/20250217080054_drop_collab_member_trigger.sql",
    "content": "CREATE OR REPLACE FUNCTION add_to_af_workspace_member()\nRETURNS TRIGGER AS $$\nBEGIN\n  IF NEW.status = 1 THEN\n    -- workspace permission\n    INSERT INTO af_workspace_member (workspace_id, uid, role_id)\n    VALUES (\n      NEW.workspace_id,\n      (SELECT uid FROM af_user WHERE email = NEW.invitee_email),\n      NEW.role_id\n    )\n    ON CONFLICT (workspace_id, uid) DO NOTHING;\n  END IF;\n  RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS af_collab_member_change_trigger ON af_collab_member;\nDROP FUNCTION IF EXISTS notify_af_collab_member_change;\n"
  },
  {
    "path": "migrations/20250226091933_blob_metadata_add_file_source.sql",
    "content": "-- Add migration script here\nALTER TABLE af_blob_metadata\nADD COLUMN source SMALLINT NOT NULL DEFAULT 0,\nADD COLUMN source_metadata JSONB DEFAULT '{}'::jsonb;"
  },
  {
    "path": "migrations/20250305082546_workspace_delete_trigger.sql",
    "content": "-- Edited on 2025-05-04 so that supabase_auth_admin role is not mandatory\n\nCREATE TABLE IF NOT EXISTS af_workspace_deleted (\n    workspace_id uuid PRIMARY KEY NOT NULL,\n    deleted_at timestamp with time zone DEFAULT now()\n);\n\n-- Workspace delete trigger\nCREATE OR REPLACE FUNCTION workspace_deleted_trigger_function()\nRETURNS trigger AS\n$$\nDECLARE\n    payload jsonb;\nBEGIN\n    payload := jsonb_build_object(\n        'workspace_id', OLD.workspace_id\n    );\n    INSERT INTO public.af_workspace_deleted (workspace_id, deleted_at)\n    VALUES (OLD.workspace_id, now())\n    ON CONFLICT DO NOTHING;\n    PERFORM pg_notify('af_workspace_deleted', payload::text);\n    RETURN OLD;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE TRIGGER on_workspace_delete\nAFTER DELETE ON public.af_workspace\nFOR EACH ROW\nEXECUTE FUNCTION workspace_deleted_trigger_function();\n\n-- Delete user trigger\nCREATE OR REPLACE FUNCTION notify_user_deletion()\nRETURNS TRIGGER AS $$\nDECLARE\n    payload TEXT;\nBEGIN\n    payload := jsonb_build_object(\n            'user_uuid', OLD.uuid::text\n    );\n\n    PERFORM pg_notify('af_user_deleted', payload::text);\n    RETURN OLD;\nEND;\n$$ LANGUAGE plpgsql;\n\nCREATE OR REPLACE TRIGGER user_deletion_trigger\nAFTER DELETE ON public.af_user\nFOR EACH ROW\nEXECUTE FUNCTION notify_user_deletion();\n"
  },
  {
    "path": "migrations/20250318120849_departition_af_collab.sql",
    "content": "set statement_timeout to 3600000;\n\n-- create new uniform collab table\ncreate table af_collab_temp\n(\n    oid uuid not null primary key,\n    workspace_id  uuid not null references public.af_workspace on delete cascade,\n    owner_uid bigint not null,\n    partition_key integer not null,\n    len integer,\n    blob bytea not null,\n    deleted_at timestamp with time zone,\n    created_at timestamp with time zone default CURRENT_TIMESTAMP,\n    updated_at timestamp with time zone default CURRENT_TIMESTAMP not null,\n    indexed_at timestamp with time zone\n);\n\n-- create a table for collabs that have non-UUID object ids\ncreate table af_collab_conflicts\n(\n    oid text not null primary key,\n    workspace_id  uuid not null references public.af_workspace on delete cascade,\n    owner_uid bigint not null,\n    partition_key integer not null,\n    len integer,\n    blob bytea not null,\n    deleted_at timestamp with time zone,\n    created_at timestamp with time zone default CURRENT_TIMESTAMP,\n    updated_at timestamp with time zone default CURRENT_TIMESTAMP not null,\n    indexed_at timestamp with time zone\n);\n\n-- move non-UUID object ids to the new table (execution time: 31 secs)\ninsert into af_collab_conflicts(oid, workspace_id, owner_uid, partition_key, len, blob, deleted_at, created_at, updated_at, indexed_at)\nselect oid, workspace_id, owner_uid, partition_key, len, blob, deleted_at, created_at, updated_at, indexed_at\nfrom af_collab\nwhere oid !~ E'^[[:xdigit:]]{8}-([[:xdigit:]]{4}-){3}[[:xdigit:]]{12}$';\n\n-- copy data from all collab partitions to new collab table (execution time: 7 mins)\ninsert into af_collab_temp(oid, workspace_id, owner_uid, partition_key, len, blob, deleted_at, created_at, updated_at, indexed_at)\nselect oid::uuid as oid, workspace_id, owner_uid, partition_key, len, blob, deleted_at, created_at, updated_at, indexed_at\nfrom af_collab\nwhere oid ~ E'^[[:xdigit:]]{8}-([[:xdigit:]]{4}-){3}[[:xdigit:]]{12}$';\n\n-- prune embeddings table\ntruncate table af_collab_embeddings;\nalter table af_collab_embeddings\n    drop constraint af_collab_embeddings_oid_partition_key_fkey;\n\n-- replace af_collab table\ndrop table af_collab;\nalter table af_collab_temp rename to af_collab;\n\n-- modify embeddings to make use of new uuid columns\nalter table af_collab_embeddings\n    alter column oid type uuid using oid::uuid;\ncreate index if not exists ix_af_collab_embeddings_oid\n    on af_collab_embeddings(oid);\n\n-- add foreign key constraint to af_collab_embeddings\nalter table af_collab_embeddings\n    add constraint fk_af_collab_embeddings_oid foreign key (oid)\n    references af_collab (oid) on delete cascade;\nalter table af_collab_embeddings drop partition_key;\n\n-- add trigger for af_collab.updated_at\ncreate trigger set_updated_at\n    before insert or update\n    on af_collab\n    for each row\n    execute procedure update_updated_at_column();\n\n-- add remaining indexes to new af_collab table (execution time: 25 sec + 25sec)\ncreate index if not exists idx_workspace_id_on_af_collab\n    on af_collab (workspace_id);\ncreate index if not exists idx_af_collab_updated_at\n    on af_collab (updated_at);\n\ncreate or replace procedure af_collab_embeddings_upsert(IN p_workspace_id uuid, IN p_oid uuid, IN p_tokens_used integer, IN p_fragments af_fragment_v3[])\n    language plpgsql\nas\n$$\nBEGIN\nDELETE FROM af_collab_embeddings WHERE oid = p_oid;\nINSERT INTO af_collab_embeddings (fragment_id, oid, content_type, content, embedding, indexed_at, metadata, fragment_index, embedder_type)\nSELECT\n    f.fragment_id,\n    p_oid,\n    f.content_type,\n    f.contents,\n    f.embedding,\n    NOW(),\n    f.metadata,\n    f.fragment_index,\n    f.embedder_type\nFROM UNNEST(p_fragments) as f;\n\n-- Update the usage tracking table\nINSERT INTO af_workspace_ai_usage(created_at, workspace_id, search_requests, search_tokens_consumed, index_tokens_consumed)\nVALUES (now()::date, p_workspace_id, 0, 0, p_tokens_used)\n    ON CONFLICT (created_at, workspace_id)\n    DO UPDATE SET index_tokens_consumed = af_workspace_ai_usage.index_tokens_consumed + p_tokens_used;\nEND\n$$;"
  },
  {
    "path": "migrations/20250403021559_workspace_invite_code.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_workspace_invite_code (\n  id UUID PRIMARY KEY DEFAULT UUID_GENERATE_V4 (),\n  invite_code TEXT NOT NULL,\n  expires_at TIMESTAMP,\n  workspace_id UUID NOT NULL REFERENCES af_workspace (workspace_id) ON DELETE CASCADE\n);\n\nCREATE INDEX idx_af_workspace_invite_code ON af_workspace_invite_code (invite_code);\n"
  },
  {
    "path": "migrations/20250405092732_af_collab_embeddings_upsert.sql",
    "content": "-- Drop existing primary key if it exists:\nALTER TABLE af_collab_embeddings\nDROP CONSTRAINT IF EXISTS af_collab_embeddings_pkey;\n\n-- Add a new composite primary key on (fragment_id, oid):\n-- Currently the fragment_id is generated by hash fragment content, so fragment_id might be\n-- conflicting with other fragments, but they are not in the same document.\nALTER TABLE af_collab_embeddings\n    ADD CONSTRAINT af_collab_embeddings_pkey\n        PRIMARY KEY (fragment_id, oid);\n\nCREATE OR REPLACE PROCEDURE af_collab_embeddings_upsert(\n    IN p_workspace_id UUID,\n    IN p_oid TEXT,\n    IN p_tokens_used INT,\n    IN p_fragments af_fragment_v3[]\n)\nLANGUAGE plpgsql\nAS\n$$\nBEGIN\n-- Delete all fragments for p_oid that are not present in the new fragment list.\nDELETE\nFROM af_collab_embeddings\nWHERE oid = p_oid\n  AND fragment_id NOT IN (\n    SELECT fragment_id FROM UNNEST(p_fragments) AS f\n);\n\n-- Use MERGE to update existing rows or insert new ones without causing duplicate key errors.\nMERGE INTO af_collab_embeddings AS t\n    USING (\n        SELECT\n            f.fragment_id,\n            p_oid AS oid,\n            f.content_type,\n            f.contents,\n            f.embedding,\n            NOW() AS indexed_at,\n            f.metadata,\n            f.fragment_index,\n            f.embedder_type\n        FROM UNNEST(p_fragments) AS f\n    ) AS s\n    ON t.oid = s.oid AND t.fragment_id = s.fragment_id\n    WHEN MATCHED THEN -- this fragment has not changed\n        UPDATE SET indexed_at = NOW()\n    WHEN NOT MATCHED THEN -- this fragment is new\n        INSERT (\n                fragment_id,\n                oid,\n                content_type,\n                content,\n                embedding,\n                indexed_at,\n                metadata,\n                fragment_index,\n                embedder_type\n            )\n            VALUES (\n                s.fragment_id,\n                s.oid,\n                s.content_type,\n                s.contents,\n                s.embedding,\n                NOW(),\n                s.metadata,\n                s.fragment_index,\n                s.embedder_type\n            );\n\n-- Update the usage tracking table with an upsert.\nINSERT INTO af_workspace_ai_usage(\n    created_at,\n    workspace_id,\n    search_requests,\n    search_tokens_consumed,\n    index_tokens_consumed\n)\nVALUES (\n           NOW()::date,\n           p_workspace_id,\n           0,\n           0,\n           p_tokens_used\n       )\n    ON CONFLICT (created_at, workspace_id)\n        DO UPDATE SET index_tokens_consumed = af_workspace_ai_usage.index_tokens_consumed + p_tokens_used;\n\nEND\n$$;"
  },
  {
    "path": "migrations/20250414074846_drop_af_collab_set_updated_at_trigger.sql",
    "content": "-- Add migration script here\nDROP TRIGGER IF EXISTS set_updated_at ON af_collab;"
  },
  {
    "path": "migrations/20250703030740_workspace_member_profile.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_workspace_member_profile (\n  uid BIGINT NOT NULL,\n  workspace_id UUID NOT NULL,\n  name TEXT,\n  avatar_url TEXT,\n  cover_image_url TEXT,\n  description TEXT,\n  PRIMARY KEY (uid, workspace_id),\n  FOREIGN KEY (uid, workspace_id) REFERENCES af_workspace_member(uid, workspace_id) ON DELETE CASCADE\n);\n"
  },
  {
    "path": "migrations/20250714060306_page_mention.sql",
    "content": "CREATE TABLE IF NOT EXISTS af_page_mention (\n  workspace_id UUID NOT NULL,\n  view_id UUID NOT NULL,\n  person_id UUID NOT NULL,\n  block_id TEXT,\n  mentioned_by BIGINT,\n  mentioned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,\n  PRIMARY KEY (workspace_id, view_id, person_id),\n  FOREIGN KEY (mentioned_by) REFERENCES af_user(uid) ON DELETE SET NULL,\n  FOREIGN KEY (workspace_id) REFERENCES af_workspace(workspace_id) ON DELETE CASCADE\n);\n"
  },
  {
    "path": "migrations/20250718033221_page_mention_notification.sql",
    "content": "ALTER TABLE af_page_mention\n  ADD COLUMN IF NOT EXISTS require_notification BOOLEAN DEFAULT FALSE;\n"
  },
  {
    "path": "migrations/20250721084910_page_mention_view_name.sql",
    "content": "ALTER TABLE af_page_mention\n  ADD COLUMN IF NOT EXISTS view_name TEXT NOT NULL DEFAULT '';\n"
  },
  {
    "path": "migrations/20250723024109_workspace_profile_custom_image_url.sql",
    "content": "ALTER TABLE af_workspace_member_profile\n  ADD COLUMN IF NOT EXISTS custom_image_url TEXT;\n"
  },
  {
    "path": "migrations/20250723072011_page_mention_notification_status.sql",
    "content": "ALTER TABLE af_page_mention\n  ADD COLUMN IF NOT EXISTS notified BOOLEAN DEFAULT FALSE;\n"
  },
  {
    "path": "nginx/nginx.conf",
    "content": "# Minimal nginx configuration for AppFlowy-Cloud\n# Self Hosted AppFlowy Cloud user should alter this file to suit their needs,\n# or use the appflowy.site.conf in external_proxy_config/nginx if they are using\n# an external proxy.\n\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    # docker dns resolver\n    resolver 127.0.0.11 valid=10s;\n    #error_log /var/log/nginx/error.log debug;\n\n    map $http_upgrade $connection_upgrade {\n        default upgrade;\n        '' close;\n    }\n\n     map $http_origin $cors_origin {\n         default $http_origin;\n      }\n\n    server {\n        listen 8080;\n\n        # https://github.com/nginxinc/nginx-prometheus-exporter\n        location = /stub_status {\n            stub_status;\n        }\n    }\n\n\n    server {\n        ssl_certificate /etc/nginx/ssl/certificate.crt;\n        ssl_certificate_key /etc/nginx/ssl/private_key.key;\n\n        listen 80;\n        listen 443 ssl;\n        client_max_body_size 10M;\n\n        underscores_in_headers on;\n        set $appflowy_cloud_backend \"http://appflowy_cloud:8000\";\n        set $gotrue_backend \"http://gotrue:9999\";\n        set $admin_frontend_backend \"http://admin_frontend:3000\";\n        set $appflowy_web_backend \"http://appflowy_web:80\";\n        set $minio_backend \"http://minio:9001\";\n        set $minio_api_backend \"http://minio:9000\";\n        # Host name for minio, used internally within docker compose\n        set $minio_internal_host \"minio:9000\";\n        set $pgadmin_backend \"http://pgadmin:80\";\n\n        # GoTrue\n        location /gotrue/ {\n            proxy_pass $gotrue_backend;\n\n            rewrite ^/gotrue(/.*)$ $1 break;\n\n            # Allow headers like redirect_to to be handed over to the gotrue\n            # for correct redirecting\n            proxy_set_header Host $http_host;\n            proxy_pass_request_headers on;\n        }\n\n        # WebSocket\n        location /ws {\n            # Existing proxy configuration\n            proxy_pass $appflowy_cloud_backend;\n\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"Upgrade\";\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_read_timeout 86400s;\n        }\n\n        location /api {\n            proxy_pass $appflowy_cloud_backend;\n            proxy_set_header X-Request-Id $request_id;\n            proxy_set_header Host $http_host;\n\n            location ~* ^/api/workspace/([a-zA-Z0-9_-]+)/publish$ {\n                proxy_pass $appflowy_cloud_backend;\n                proxy_request_buffering off;\n                client_max_body_size 256M;\n            }\n\n            # AppFlowy-Cloud\n            location /api/chat {\n                proxy_pass $appflowy_cloud_backend;\n\n                proxy_http_version 1.1;\n                proxy_set_header Connection \"\";\n                chunked_transfer_encoding on;\n                proxy_buffering off;\n                proxy_cache off;\n\n                proxy_read_timeout 600s;\n                proxy_connect_timeout 600s;\n                proxy_send_timeout 600s;\n            }\n\n            location /api/import {\n                proxy_pass $appflowy_cloud_backend;\n\n                # Set headers\n                proxy_set_header X-Request-Id $request_id;\n                proxy_set_header Host $http_host;\n\n                # Timeouts\n                proxy_read_timeout 600s;\n                proxy_connect_timeout 600s;\n                proxy_send_timeout 600s;\n\n                # Disable buffering for large file uploads\n                proxy_request_buffering off;\n                proxy_buffering off;\n                proxy_cache off;\n                client_max_body_size 2G;\n            }\n        }\n\n        # Minio Web UI\n        # Derive from: https://min.io/docs/minio/linux/integrations/setup-nginx-proxy-with-minio.html\n        # Optional Module, comment this section if you did not deploy minio in docker-compose.yml\n        # This endpoint is meant to be used for the MinIO Web UI, accessible via the admin portal\n        location /minio/ {\n            proxy_pass $minio_backend;\n\n            rewrite ^/minio/(.*) /$1 break;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header X-NginX-Proxy true;\n\n            ## This is necessary to pass the correct IP to be hashed\n            real_ip_header X-Real-IP;\n\n            proxy_connect_timeout 300s;\n\n            ## To support websockets in MinIO versions released after January 2023\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n            # Some environments may encounter CORS errors (Kubernetes + Nginx Ingress)\n            # Uncomment the following line to set the Origin request to an empty string\n            # proxy_set_header Origin '';\n\n            chunked_transfer_encoding off;\n        }\n\n        # Optional Module, comment this section if you did not deploy minio in docker-compose.yml\n        # This is used for presigned url, which is needs to be exposed to the AppFlowy client application.\n        location /minio-api/ {\n            proxy_pass $minio_api_backend;\n\n            # Set the host to internal host because the presigned url was signed against the internal host\n            proxy_set_header Host $minio_internal_host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n\n            rewrite ^/minio-api/(.*) /$1 break;\n\n            proxy_connect_timeout 300s;\n            proxy_read_timeout 600s;\n            proxy_send_timeout 600s;\n            proxy_request_buffering off;\n            # Default is HTTP/1, keepalive is only enabled in HTTP/1.1\n            proxy_http_version 1.1;\n            proxy_set_header Connection \"\";\n            chunked_transfer_encoding off;\n            client_max_body_size 0;\n        }\n\n        # PgAdmin\n        # Optional Module, comment this section if you did not deploy pgadmin in docker-compose.yml\n        location /pgadmin/ {\n            set $pgadmin pgadmin;\n            proxy_pass $pgadmin_backend;\n\n            proxy_set_header X-Script-Name /pgadmin;\n            proxy_set_header X-Scheme $scheme;\n            proxy_set_header Host $host;\n            proxy_redirect off;\n        }\n\n        # AI\n        location /ai/ {\n            proxy_pass $appflowy_cloud_backend;\n            proxy_set_header X-Request-Id $request_id;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n        }\n\n        # Admin Frontend\n        location /console {\n            proxy_pass $admin_frontend_backend;\n\n            proxy_set_header X-Forwarded-Host $http_host;\n            proxy_set_header Host $http_host;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Scheme $scheme;\n\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection 'upgrade';\n            proxy_cache_bypass $http_upgrade;\n        }\n\n        # AppFlowy Web\n        location / {\n            proxy_pass $appflowy_web_backend;\n            proxy_set_header X-Scheme $scheme;\n            proxy_set_header Host $host;\n        }\n    }\n\n}\n"
  },
  {
    "path": "nginx/ssl/certificate.crt",
    "content": "-----BEGIN CERTIFICATE-----\nMIIFRDCCAywCCQDXwkFioxoJ2TANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQGEwJV\nUzETMBEGA1UECAwKQ2FsaWZvcm5pYTESMBAGA1UEBwwJU3Vubnl2YWxlMRYwFAYD\nVQQKDA1BcHBGbG93eSxJbmMuMRQwEgYDVQQDDAthcHBmbG93eS5pbzAeFw0yMzAz\nMTUwMDUxNDVaFw0yNDAzMTQwMDUxNDVaMGQxCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRIwEAYDVQQHDAlTdW5ueXZhbGUxFjAUBgNVBAoMDUFwcEZs\nb3d5LEluYy4xFDASBgNVBAMMC2FwcGZsb3d5LmlvMIICIjANBgkqhkiG9w0BAQEF\nAAOCAg8AMIICCgKCAgEA09v/ouq4r7+oLOWqVscYpW5QRLR5O6OYZprocIARAtWA\nqBkywhPku/SZq27dtPD7Pi3soSPkMhYDFALai4idgELCFxxkTuHWNm3J+Y8PcMq2\nRX325/pQVpOMTkChqaUzh93ynYqv89x3lT9z4saknBde/WO2yOJ6sfED9w+ezYgm\n34LV5Z0cofQTDEiTX58KV3MmG5hRMdBwCaDg1jUb3jKr5lBrF4+EHbAN5PWH282V\nJdyOTvZp/CF5TcnAMONkYENjURpnSXJes34ufYHkmr0eDa+2pfc3TI5wlB3tPQyN\np+B5TsDCDofv0Zme5Ur42TWcwsG0WRvtDw2KZ98wBtGaIv8UEQjXipNQVzeCv5Yv\nzeykjdDhOJ/OZFzsm2vtl55t52M43xYMo3QPmjHMiVYz9KVvPqrVo+O5PI7B7uwY\nJxWQIJosUa6AeKfkCAQd8mlNqYylqV8Utqs8b4zee8Vf7hzaCYNKxyDvqsd3yDwE\numh5zVuxJitLchDFT4mv1v3yLHocusV2lwjfEk66R/o5BBDdviycxeAM5Q1lyi7M\nRywHAt/eVQNaBq0HSa2vbIm5yTZNQYwuhnNgv035hf8vpu+tFOGepTqgy/CrYpPm\nVnsClJVrmxd4LfUZ0aZODiKCVx6psfeBvTXu1r7/SjsmbvYE4ubfM66optyFc3cC\nAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAfhSUTP91rBP+8zvsoxomKdeClVFURczr\nHO/VuXVmBoKLASFqFcT84usRhb5T6XB7o2GcCYSo0VFJw99UM6nLsZ7c0MKaAjT6\n/9VeyLtDfhCDkffGGxpeYhme+0PY8TXIU5aO0ZhQwzXUOiC7t3Ac3AzHTPGrGqOK\naAGhMo7V2bQXcNR6NFhsUlJOtVE59MxL1K5Ug1oMn/H+NUF6/st+KzohruQJSSWG\nGcbqVor0zZbFJSvRJ5P3ngw2cg2SIj9w6RwUWMp+a5kOx10fOYrQEHGyTHFlVDy0\nyOCU4eVO3EVTm7Se1XVwmG3kNKQaLFJf1voMuYD2sFbZ0nhGJDSZOcGUrchUXPQB\nC9MwU52OeNm5VwE/41wLoFvOkJ/I/Ak7vccl1YJXpefa6qjNOFm5X0jA7D4egDfC\nIVs5m30qa6Birx0xS6RUuuvxLJyNzgLSzsC1eFwjR2uwIUrGpYo3YI4+bMxp2Wnk\n6qtm5G8D1giWg6z0RLw+GSj4QfcJEBP+zyiH/MdB5te9kXVYLKyS+DGgTTatLi1l\nMBBK5b7dvfwo08J/sksK+mPHHBsV9TPAkqMp9vuZw25pRAEnvjEOUPfi3X9EZDw7\nA3LElN9KDks3IioioAOm0vPvHrlfziSljt5IMkuZLT3lUe6B3cs69caLO0S/ZDTz\nyqGwLIqHYos=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "nginx/ssl/private_key.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQDT2/+i6rivv6gs\n5apWxxilblBEtHk7o5hmmuhwgBEC1YCoGTLCE+S79Jmrbt208Ps+LeyhI+QyFgMU\nAtqLiJ2AQsIXHGRO4dY2bcn5jw9wyrZFffbn+lBWk4xOQKGppTOH3fKdiq/z3HeV\nP3PixqScF179Y7bI4nqx8QP3D57NiCbfgtXlnRyh9BMMSJNfnwpXcyYbmFEx0HAJ\noODWNRveMqvmUGsXj4QdsA3k9YfbzZUl3I5O9mn8IXlNycAw42RgQ2NRGmdJcl6z\nfi59geSavR4Nr7al9zdMjnCUHe09DI2n4HlOwMIOh+/RmZ7lSvjZNZzCwbRZG+0P\nDYpn3zAG0Zoi/xQRCNeKk1BXN4K/li/N7KSN0OE4n85kXOyba+2Xnm3nYzjfFgyj\ndA+aMcyJVjP0pW8+qtWj47k8jsHu7BgnFZAgmixRroB4p+QIBB3yaU2pjKWpXxS2\nqzxvjN57xV/uHNoJg0rHIO+qx3fIPAS6aHnNW7EmK0tyEMVPia/W/fIsehy6xXaX\nCN8STrpH+jkEEN2+LJzF4AzlDWXKLsxHLAcC395VA1oGrQdJra9sibnJNk1BjC6G\nc2C/TfmF/y+m760U4Z6lOqDL8Ktik+ZWewKUlWubF3gt9RnRpk4OIoJXHqmx94G9\nNe7Wvv9KOyZu9gTi5t8zrqim3IVzdwIDAQABAoICAQC7fCxdc5TfSx+8I767rtO7\nysTUGFZVFfCPlLTwohTryh9iI3KM1+gLAWpgkOs47i2ZGDEZZVbTkDFHK0NWSh7/\n25RBuYl3WVolrsEXzaefbHUjSFcRca5Y/5ghxAaMx7qzmRHUo2AU0d0twgp+/MW9\nsN0KJo0id3KXODAHGtaxErU8BV/fJEurcwDMVQm+jFMtkqR9tSzdhZUwoCN4zWUN\nHRCM8EvlfMcxMpUJMtP5C5Ta/bUeYejnDIR593nSidlRazFgG5qeH8140Mi5nxK8\ncXJAMGjVtNJGOKOeIGHLLenKT9dqfyD8lQYBGg7I4bEZH93LaHp+hT0jnhsG0zd+\npEvXCX9IEIzajKQPv6wdLup+p2hZBTq7SddILliM28y0vbNXhmNFBsrLnWTMeGpl\nn0VoGytmFO/b09S2yd0glrBZZyFmnPzi0dVk341mFaFFcXot/xum73FWt2Cy77Vp\nlGfFlI4TzQbmJKWYNPJl1BiZWOKSXWtNn7lVACBJfOjoifMHdzkjXSKxv21Nompy\nY08Bl2wSfM3plQ6kbmu7KsGuVE2OH1oBbYYxjwFWQAl/ISeEJztqWeLzTYvTFYCl\ngA2NC1MJHdJZWU71m0XQN7Cs8mQ0lQhlCTm3Y7Mlbm8RCgJvmfPECyWX1KbeKzgq\nAQ4tUotGeuMUX6KIoNX7+QKCAQEA9/jTGRuXu7zsDae9VAZHluZ55Jc1XRUPYgIr\nAMLn3J88RD8XOaW9ZiIt+btfCx3WmxwFwLjD4g15QgwS1LoRGYzNjxCv04naplOT\npwfVT3Ry5BbfAMbx1GrGyNH9lh479aozvtXudL5QzirTXDSVlwQ4K3VvL4XHNtOh\n3ZiFNReUKdu2fTPPPUc3vs2XAG3fWb8G7KWthbbdHAhl//gyZI6iAR2Fc3IHZGc4\n+Xuqmlvccx3+ZWhksf4uOfzEluruYlv8AgczxtKB4tWsoYJbU71zdYHAtPaYua8w\n6x9urD7vLNQ7TpagCD8q8V5jX+XG53HPBMJ5hRwy35bW0SJLEwKCAQEA2rfgByLa\n00ghuyrF99ynwt5lZGk+WYR74xLn21PfP4vwWsdEnUNCDzBaZsKFOMsx5nctYVKk\nZfGCYRaLho4GNifkf6yy2QIMCq1bayYJlISDQXgjgVpZXjsnOotYjPDglGSIOhij\nXaLSqGbvDt/VtIPwTeNHfSnsRQzzd0XxclbP96mIxLrcjvOUlWyavUVh58dkv/pi\nR+7dE9b46zyCxc9OuTdZ9RAc6Op3DsHXk+Yuwrwh5r2rBEQxkqQ4//gQJKjGtfFS\nYwI9bmsZnTYoalTtVjaDZ1mDlYbGgHzecplw59lQluuNJMFTEjpkK1E67oAFbkry\nwRxnUkSYRq6+jQKCAQEA1BGI98ARVA2OE1+RG3sDXppdRJHMoX6RWVBhVpVZleTY\ntcT/J94GzIIOr7T+45LxJlYg1WEupPTA7ytEL4mxdhhk9CVhOZh71iND82VPmFQO\nreKhdRivWOq4dqagKPJSdRbKijqLZGwezzLw77pI9I43O3ODUzEl3k2/8LOvuGgh\n3mp49zqH0fBGTHem3Eca7LXiRiCq9eAd2QuVsAOjlTwmcK2+o6yxhbyBjVul270U\nG59bIX7WHyMyhYUW27qvhI8GRvXB4hfF3SjAKqBBWqx7QdNl612535NkUrDfBZAN\nHFmlHuDSnDrpjuMaOblZEjbSxU9MffpPx8hIjzK04QKCAQEAtX2LCqDjkBr00okF\nyU1ycAN3g0DJmiKTYrPXbWpFgEew5MMhrpWXBV+MRGT5g00pVSJjp7SZ8nXbSJEa\nqkbD5MBpnYBC0EwgjeOYTms729+xwuvcGoRMUCMpxCzJB/sBgGGDoSG8vgBUaaUw\njdkzTh2FlDwaoEPfaNT8WmbRmZ1r6QjnEsg0KPL6wptiM9iVC22rrpooX6RYExR5\nbUnDAj2qB4tkvDPoqWWV8crsBjAlcTYHs56DgIDN2e8n1U+UpbbfXS6ovLupGi0J\nDilYlBNw9e86TtI6nCNAKHJ1bAbjZ6AufW1sq6k4M5H8eO1ox2u4FfNfSNs26U8+\nRLjQKQKCAQAfd+u6/EEA4bMzVuE7SWpYD87eQ4edttjda4tlJBO48KFtqZ2bhIKd\nsAEdw3txbcHiPazFQlNgKBfxq9JhGX8Dga/Wx/s/d5eTafqXfQk4hfHHqoYY4D+H\nagDdP3QPVnfBueTcdKnGuD5Ex1pK4pnmnRkKQd1XOlV2w49PeoA1HG2PvsvSyfoa\nyxfTIRsX78I4wClQywEnyGWKvsOGSP/zHHfKaCoic/KwDx5SVgeZCLgSoDHWdpuh\nVu5JGnIFQel7Y6+Zd92ubZ1vFUW7hW0JPHszSGqg2aE1m5RXsIanhTUCQusR3Pj+\nOi+igzFlcelWDZ/eQ8CpDkSpAqtwwx97\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "rust-toolchain.toml",
    "content": "[toolchain]\nchannel = \"1.86.0\"\n"
  },
  {
    "path": "rustfmt.toml",
    "content": "# https://rust-lang.github.io/rustfmt/?version=master&search=\nmax_width = 100\ntab_spaces = 2\nnewline_style = \"Auto\"\nmatch_block_trailing_comma = true\nuse_field_init_shorthand = true\nuse_try_shorthand = true\nreorder_imports = true\nreorder_modules = true\nremove_nested_parens = true\nmerge_derives = true\nedition = \"2024\""
  },
  {
    "path": "script/client_api_deps_check.sh",
    "content": "#!/bin/bash\n\n# Generate the current dependency list\ncargo tree > current_deps.txt\n\nBASELINE_COUNT=847\nCURRENT_COUNT=$(cat current_deps.txt | wc -l)\n\necho \"Expected dependency count (baseline): $BASELINE_COUNT\"\necho \"Current dependency count: $CURRENT_COUNT\"\n\nif [ \"$CURRENT_COUNT\" -gt \"$BASELINE_COUNT\" ]; then\n    echo \"Dependency count has increased!\"\n    exit 1\nelse\n    echo \"No increase in dependencies.\"\nfi\n"
  },
  {
    "path": "script/code_gen.sh",
    "content": "#!/usr/bin/env bash\nset -x\nset -eo pipefail\n\n# Generate protobuf files for collab-rt-entity crate.\ncargo build -p collab-rt-entity"
  },
  {
    "path": "script/diagnose_appflowy.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool\n# =============================================================================\n# This script diagnoses common issues with AppFlowy Cloud docker-compose\n# deployments, particularly login and authentication problems.\n#\n# Usage: ./script/diagnose_appflowy.sh [OPTIONS]\n#\n# Options:\n#   -h, --help              Show help message\n#   -v, --verbose           Verbose output (show all checks)\n#   -q, --quiet             Minimal output (errors only)\n#   -f, --compose-file FILE Specify docker-compose file\n#   -o, --output FILE       Save report to specific file\n#   -l, --logs              Include full log analysis\n#   -s, --skip-logs         Skip log analysis (faster)\n#   --no-color              Disable colored output\n#   --json                  Output in JSON format\n#   --quick                 Quick mode (skip slow checks)\n#\n# Features:\n# - Container health checks\n# - Configuration validation\n# - JWT secret verification\n# - Database connectivity\n# - Log analysis\n# - Actionable recommendations\n# =============================================================================\n\nset -e  # Exit on any error\n\n# ==================== INITIALIZATION ====================\n\n# Color codes for better output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nPURPLE='\\033[0;35m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\n\n# Global variables\nSCRIPT_VERSION=\"2.0.0\"\nPROJECT_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nCOMPOSE_FILE=\"\"\nCOMPOSE_CMD=\"\"\nOUTPUT_FILE=\"\"\nVERBOSE=false\nQUIET=false\nINCLUDE_LOGS=false\nSKIP_LOGS=false\nNO_COLOR=false\nJSON_OUTPUT=false\nQUICK_MODE=false\n\n# Check results\ndeclare -a ISSUES=()\ndeclare -a WARNINGS=()\ndeclare -a SUCCESSES=()\n\n# Service version info\nAPPFLOWY_CLOUD_VERSION=\"\"\nADMIN_FRONTEND_VERSION=\"\"\nDEPLOYMENT_TYPE=\"\"\n\n# Timestamp for report\nTIMESTAMP=$(date +\"%Y%m%d_%H%M%S\")\nREPORT_FILE=\"$PROJECT_ROOT/appflowy_diagnostic_$TIMESTAMP.log\"\n\n# ==================== LOAD MODULES ====================\n\n# Load utility functions\nsource \"$SCRIPT_DIR/lib/utils.sh\"\n\n# Load check modules\nsource \"$SCRIPT_DIR/lib/check_containers.sh\"\nsource \"$SCRIPT_DIR/lib/check_config.sh\"\nsource \"$SCRIPT_DIR/lib/check_health.sh\"\nsource \"$SCRIPT_DIR/lib/check_functional.sh\"\nsource \"$SCRIPT_DIR/lib/check_logs.sh\"\nsource \"$SCRIPT_DIR/lib/report.sh\"\n\n# ==================== MAIN FUNCTION ====================\n\nmain() {\n    parse_arguments \"$@\"\n\n    # Print header\n    if [[ \"$JSON_OUTPUT\" != \"true\" ]]; then\n        print_header \"AppFlowy Cloud Diagnostic Tool v${SCRIPT_VERSION}\"\n        echo \"\"\n    fi\n\n    # Phase 1: Environment Check\n    if [[ \"$QUIET\" != \"true\" ]]; then\n        print_header \"Phase 1: Environment Check\"\n    fi\n\n    check_docker || exit 1\n    check_docker_compose > /dev/null || exit 1\n    detect_compose_file || exit 1\n    check_env_file || exit 1\n\n    echo \"\"\n\n    # Phase 2: Container Status\n    if [[ \"$QUIET\" != \"true\" ]]; then\n        print_header \"Phase 2: Container Status\"\n    fi\n\n    check_container_status\n    check_service_versions\n\n    echo \"\"\n\n    # Phase 3: Configuration Validation\n    if [[ \"$QUIET\" != \"true\" ]]; then\n        print_header \"Phase 3: Configuration Validation\"\n    fi\n\n    check_duplicate_env_keys\n    check_legacy_env_vars\n    check_jwt_secrets\n    check_database_urls\n    check_base_urls\n    check_scheme_consistency\n    check_gotrue_configuration\n    check_user_auth_flow\n    check_admin_credentials\n    check_smtp_configuration\n    check_ai_server_configuration\n    check_nginx_websocket_config\n    check_production_https_websocket\n    check_ssl_certificate\n    check_websocket_cors_headers\n    check_plan_limits\n\n    echo \"\"\n\n    # Phase 4: Health Checks\n    if [[ \"$QUICK_MODE\" != \"true\" ]]; then\n        if [[ \"$QUIET\" != \"true\" ]]; then\n            print_header \"Phase 4: Health Checks\"\n        fi\n\n        check_health_endpoints\n\n        echo \"\"\n    fi\n\n    # Phase 5: Functional Tests\n    if [[ \"$QUICK_MODE\" != \"true\" ]]; then\n        if [[ \"$QUIET\" != \"true\" ]]; then\n            print_header \"Phase 5: Functional Tests\"\n        fi\n\n        run_functional_tests\n\n        echo \"\"\n    fi\n\n    # Phase 6: Log Analysis\n    if [[ \"$SKIP_LOGS\" != \"true\" ]]; then\n        if [[ \"$QUIET\" != \"true\" ]]; then\n            print_header \"Phase 6: Log Analysis\"\n        fi\n\n        check_admin_frontend_connectivity\n        check_admin_frontend_errors\n        check_gotrue_auth_errors\n        check_container_errors\n        extract_container_crash_summary\n        analyze_service_logs\n\n        echo \"\"\n    fi\n\n    # Summary\n    if [[ \"$QUIET\" != \"true\" ]]; then\n        print_header \"Summary\"\n        echo \"\"\n        echo \"Successes: ${#SUCCESSES[@]}\"\n        echo \"Warnings:  ${#WARNINGS[@]}\"\n        echo \"Issues:    ${#ISSUES[@]}\"\n        echo \"\"\n\n        if [[ ${#ISSUES[@]} -gt 0 ]]; then\n            print_header \"Recommendations\"\n            generate_recommendations\n            echo \"\"\n        fi\n    fi\n\n    # Generate report\n    generate_report\n\n    # Exit code based on issues\n    if [[ ${#ISSUES[@]} -gt 0 ]]; then\n        exit 1\n    else\n        exit 0\n    fi\n}\n\n# Run main function\nmain \"$@\"\n"
  },
  {
    "path": "script/generate_env.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Environment File Generator\n# =============================================================================\n# This script generates a .env file from deploy.env or dev.env with optional\n# environment-specific secret files for private values.\n#\n# Usage: ./script/generate_env.sh\n#\n# Features:\n# - Interactive selection of base environment file (deploy.env or dev.env)\n# - Optional environment-specific secret files (.env.deploy.secret or .env.dev.secret)\n# - Generates final .env file in the project root\n# =============================================================================\n\nset -e  # Exit on any error\n\n# Color codes for better output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nPURPLE='\\033[0;35m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\n\n# Project root directory (one level up from script directory)\nPROJECT_ROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n\n# Function to print colored output\nprint_header() {\n    echo -e \"${BLUE}=================================${NC}\"\n    echo -e \"${BLUE}$1${NC}\"\n    echo -e \"${BLUE}=================================${NC}\"\n}\n\nprint_success() {\n    echo -e \"${GREEN}✓ $1${NC}\"\n}\n\nprint_warning() {\n    echo -e \"${YELLOW}⚠ $1${NC}\"\n}\n\nprint_error() {\n    echo -e \"${RED}✗ $1${NC}\"\n}\n\nprint_info() {\n    echo -e \"${CYAN}ℹ $1${NC}\"\n}\n\n# Function to check if file exists\ncheck_file_exists() {\n    local file_path=\"$1\"\n    if [[ ! -f \"$file_path\" ]]; then\n        print_error \"File does not exist: $file_path\"\n        return 1\n    fi\n    return 0\n}\n\n# Function to display menu and get user selection\nselect_env_file() {\n    print_header \"Select Base Environment File\"\n    echo \"Please choose which environment configuration to use as base:\"\n    echo\n    echo \"1) deploy.env  - Production deployment configuration\"\n    echo \"2) dev.env     - Development environment configuration\"\n    echo \"3) Exit\"\n    echo\n    \n    while true; do\n        read -p \"Enter your choice (1-3): \" choice\n        case $choice in\n            1)\n                selected_env=\"deploy.env\"\n                break\n                ;;\n            2)\n                selected_env=\"dev.env\"\n                break\n                ;;\n            3)\n                print_info \"Exiting...\"\n                exit 0\n                ;;\n            *)\n                print_error \"Invalid choice. Please enter 1, 2, or 3.\"\n                ;;\n        esac\n    done\n    \n    # Verify the selected file exists\n    local env_file_path=\"$PROJECT_ROOT/$selected_env\"\n    if ! check_file_exists \"$env_file_path\"; then\n        print_error \"Selected environment file '$selected_env' not found in project root.\"\n        exit 1\n    fi\n    \n    print_success \"Selected: $selected_env\"\n    echo\n}\n\n# Function to check for environment-specific secret file\ncheck_env_secret_file() {\n    local env_type=\"\"\n    if [[ \"$selected_env\" == \"deploy.env\" ]]; then\n        env_type=\"deploy\"\n    elif [[ \"$selected_env\" == \"dev.env\" ]]; then\n        env_type=\"dev\"\n    fi\n    \n    local secret_file_path=\"$PROJECT_ROOT/.env.${env_type}.secret\"\n    if [[ -f \"$secret_file_path\" ]]; then\n        echo \"$secret_file_path\"\n    fi\n}\n\n# Function to select private secret file\nselect_private_secret_file() {\n    print_header \"Use Private Secret File (Optional)\"\n    \n    echo \"Private secret files allow you to override specific environment variables\"\n    echo \"with sensitive values (like API keys, passwords, etc.)\"\n    echo\n    \n    # Check if environment-specific secret file exists\n    local secret_file_path=$(check_env_secret_file)\n    local env_type=\"\"\n    if [[ \"$selected_env\" == \"deploy.env\" ]]; then\n        env_type=\"deploy\"\n    elif [[ \"$selected_env\" == \"dev.env\" ]]; then\n        env_type=\"dev\"\n    fi\n    \n    if [[ -n \"$secret_file_path\" ]]; then\n        print_success \"Found .env.${env_type}.secret file in project root\"\n        echo\n        echo \"1) Use .env.${env_type}.secret for private values\"\n        echo \"2) Continue without private secrets\"\n        echo \"3) Exit\"\n        echo\n        \n        while true; do\n            read -p \"Enter your choice (1-3): \" choice\n            case $choice in\n                1)\n                    selected_secret_file=\"$secret_file_path\"\n                    break\n                    ;;\n                2)\n                    selected_secret_file=\"\"\n                    break\n                    ;;\n                3)\n                    print_info \"Exiting...\"\n                    exit 0\n                    ;;\n                *)\n                    print_error \"Invalid choice. Please enter 1, 2, or 3.\"\n                    ;;\n            esac\n        done\n    else\n        print_warning \".env.${env_type}.secret file not found in project root.\"\n        echo \"You can create a .env.${env_type}.secret file with KEY=VALUE pairs to override\"\n        echo \"sensitive environment variables and run this script again.\"\n        echo\n        echo \"1) Continue without private secrets\"\n        echo \"2) Exit\"\n        echo\n        \n        while true; do\n            read -p \"Enter your choice (1-2): \" choice\n            case $choice in\n                1)\n                    selected_secret_file=\"\"\n                    break\n                    ;;\n                2)\n                    print_info \"Exiting...\"\n                    exit 0\n                    ;;\n                *)\n                    print_error \"Invalid choice. Please enter 1 or 2.\"\n                    ;;\n            esac\n        done\n    fi\n    \n    if [[ -n \"$selected_secret_file\" ]]; then\n        print_success \"Will use .env.${env_type}.secret for private values\"\n    else\n        print_info \"Continuing without private secrets.\"\n    fi\n    echo\n}\n\n# Function to extract keys from a file\nextract_keys_from_file() {\n    local file_path=\"$1\"\n    local keys=()\n    \n    # Extract environment variable keys (handle various formats)\n    while IFS= read -r line; do\n        # Skip comments and empty lines\n        if [[ \"$line\" =~ ^[[:space:]]*# ]] || [[ \"$line\" =~ ^[[:space:]]*$ ]]; then\n            continue\n        fi\n        \n        # Extract key from KEY=VALUE format\n        if [[ \"$line\" =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)[[:space:]]*= ]]; then\n            local key=\"${BASH_REMATCH[1]}\"\n            keys+=(\"$key\")\n        fi\n    done < \"$file_path\"\n    \n    echo \"${keys[@]}\"\n}\n\n# Function to generate the final .env file\ngenerate_env_file() {\n    print_header \"Generating .env File\"\n    \n    local base_env_path=\"$PROJECT_ROOT/$selected_env\"\n    local output_path=\"$PROJECT_ROOT/.env\"\n    \n    # Check if .env file already exists\n    if [[ -f \"$output_path\" ]]; then\n        print_warning \".env file already exists in project root\"\n        echo\n        echo \"1) Override existing .env file\"\n        echo \"2) Cancel and exit\"\n        echo\n        \n        while true; do\n            read -p \"Enter your choice (1-2): \" choice\n            case $choice in\n                1)\n                    print_info \"Will override existing .env file...\"\n                    # Create backup of existing .env file\n                    local backup_path=\"$PROJECT_ROOT/.env.backup.$(date +%Y%m%d_%H%M%S)\"\n                    cp \"$output_path\" \"$backup_path\"\n                    print_success \"Created backup: $(basename \"$backup_path\")\"\n                    break\n                    ;;\n                2)\n                    print_info \"Operation cancelled by user.\"\n                    print_info \"Your existing .env file has been preserved.\"\n                    exit 0\n                    ;;\n                *)\n                    print_error \"Invalid choice. Please enter 1 or 2.\"\n                    ;;\n            esac\n        done\n        echo\n    fi\n    \n    # Start with base environment file\n    print_info \"Copying base environment from $selected_env...\"\n    cp \"$base_env_path\" \"$output_path\"\n    \n    if [[ -n \"$selected_secret_file\" ]]; then\n        print_info \"Processing private secret file...\"\n        \n        # Extract keys from secret file\n        local secret_keys=()\n        while IFS= read -r key; do\n            if [[ -n \"$key\" ]]; then\n                secret_keys+=(\"$key\")\n            fi\n        done < <(extract_keys_from_file \"$selected_secret_file\" | tr ' ' '\\n')\n        \n        if [[ ${#secret_keys[@]} -eq 0 ]]; then\n            print_warning \"No valid environment variables found in private secret file.\"\n        else\n            print_info \"Found ${#secret_keys[@]} keys in private secret file: ${secret_keys[*]}\"\n            \n            # Create a temporary file for processing\n            local temp_file=$(mktemp)\n            cp \"$output_path\" \"$temp_file\"\n            \n            # Replace keys that exist in both files\n            local replaced_count=0\n            while IFS= read -r line; do\n                # Skip comments and empty lines in secret file\n                if [[ \"$line\" =~ ^[[:space:]]*# ]] || [[ \"$line\" =~ ^[[:space:]]*$ ]]; then\n                    continue\n                fi\n                \n                # Extract key-value pair from secret file\n                if [[ \"$line\" =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*) ]]; then\n                    local secret_key=\"${BASH_REMATCH[1]}\"\n                    local secret_value=\"${BASH_REMATCH[2]}\"\n                    \n                    # Skip if secret value is empty\n                    if [[ -z \"$secret_value\" ]]; then\n                        print_warning \"Key '$secret_key' has empty value in secret file - skipping\"\n                        continue\n                    fi\n                    \n                    # Check if this key exists in the base environment file\n                    if grep -q \"^[[:space:]]*${secret_key}[[:space:]]*=\" \"$base_env_path\"; then\n                        # Replace the key in the output file using a line-by-line approach\n                        local temp_file_new=\"${temp_file}.new\"\n                        local found_and_replaced=false\n                        \n                        while IFS= read -r env_line; do\n                            # Check if this line matches our target key\n                            if [[ \"$env_line\" =~ ^[[:space:]]*${secret_key}[[:space:]]*= ]]; then\n                                # Replace the entire line with the new key=value\n                                echo \"${secret_key}=${secret_value}\" >> \"$temp_file_new\"\n                                found_and_replaced=true\n                            else\n                                # Keep the original line\n                                echo \"$env_line\" >> \"$temp_file_new\"\n                            fi\n                        done < \"$temp_file\"\n                        \n                        if [[ \"$found_and_replaced\" == true ]]; then\n                            mv \"$temp_file_new\" \"$temp_file\"\n                            ((replaced_count++))\n                            print_success \"Replaced: $secret_key\"\n                        else\n                            rm -f \"$temp_file_new\"\n                            print_warning \"Failed to find and replace: $secret_key\"\n                        fi\n                    else\n                        print_warning \"Key '$secret_key' from secret file not found in base environment file - skipping\"\n                    fi\n                fi\n            done < \"$selected_secret_file\"\n            \n            # Replace the original output file\n            mv \"$temp_file\" \"$output_path\"\n            \n            if [[ $replaced_count -gt 0 ]]; then\n                print_success \"Successfully replaced $replaced_count environment variables with private values\"\n            else\n                print_warning \"No matching keys found between base environment and private secret files\"\n            fi\n        fi\n    fi\n    \n    print_success \"Generated .env file successfully!\"\n    print_info \"Output file: $output_path\"\n    \n    # Show summary\n    local total_vars=$(grep -c '^[A-Z_][A-Z0-9_]*=' \"$output_path\" 2>/dev/null || echo \"0\")\n    print_info \"Total environment variables: $total_vars\"\n    \n    echo\n    print_header \"Summary\"\n    echo \"Base environment file: $selected_env\"\n    if [[ -n \"$selected_secret_file\" ]]; then\n        local env_type=\"\"\n        if [[ \"$selected_env\" == \"deploy.env\" ]]; then\n            env_type=\"deploy\"\n        elif [[ \"$selected_env\" == \"dev.env\" ]]; then\n            env_type=\"dev\"\n        fi\n        echo \"Private secret file: .env.${env_type}.secret\"\n    else\n        echo \"Private secret file: None\"\n    fi\n    echo \"Output file: .env\"\n    # Check if backup was created (look for recent backup files)\n    local recent_backup=$(find \"$PROJECT_ROOT\" -name \".env.backup.*\" -newer \"$PROJECT_ROOT/$selected_env\" 2>/dev/null | head -1)\n    if [[ -n \"$recent_backup\" ]]; then\n        echo \"Backup created: $(basename \"$recent_backup\")\"\n    fi\n    echo \"Total variables: $total_vars\"\n    echo\n    print_success \"Environment file generation complete!\"\n}\n\n# Function to show help\nshow_help() {\n    echo \"AppFlowy Cloud Environment File Generator\"\n    echo\n    echo \"This script helps you generate a .env file from either deploy.env or dev.env\"\n    echo \"with optional environment-specific secret file integration.\"\n    echo\n    echo \"Usage: $0 [OPTIONS]\"\n    echo\n    echo \"Options:\"\n    echo \"  -h, --help    Show this help message\"\n    echo\n    echo \"Interactive Features:\"\n    echo \"  • Select base environment file (deploy.env or dev.env)\"\n    echo \"  • Optionally use environment-specific secret files for private values\"\n    echo \"  • Automatically replace matching keys with private values\"\n    echo \"  • Generate final .env file in project root\"\n    echo\n    echo \"Private Secret File Format:\"\n    echo \"  Create .env.deploy.secret or .env.dev.secret files with KEY=VALUE format.\"\n    echo \"  Only keys that exist in both the base environment file and secret file\"\n    echo \"  will be replaced.\"\n    echo\n    echo \"Examples:\"\n    echo \"  $0                    # Run interactive mode\"\n    echo \"  $0 --help           # Show this help\"\n}\n\n# Main execution\nmain() {\n    # Parse command line arguments\n    case \"${1:-}\" in\n        -h|--help)\n            show_help\n            exit 0\n            ;;\n        \"\")\n            # Interactive mode - continue with script\n            ;;\n        *)\n            print_error \"Unknown option: $1\"\n            show_help\n            exit 1\n            ;;\n    esac\n    \n    print_header \"AppFlowy Cloud Environment Generator\"\n    echo \"This script will help you generate a .env file from your environment templates.\"\n    echo\n    \n    # Step 1: Select base environment file\n    select_env_file\n    \n    # Step 2: Select private secret file (optional)\n    select_private_secret_file\n    \n    # Step 3: Generate the final .env file\n    generate_env_file\n    \n    echo\n    print_info \"You can now use the generated .env file with your AppFlowy Cloud deployment.\"\n    print_info \"Remember to never commit .env files containing sensitive information to version control!\"\n}\n\n# Run main function\nmain \"$@\"\n"
  },
  {
    "path": "script/lib/README.md",
    "content": "# AppFlowy Cloud Diagnostic Tool - Modular Architecture\n\nThis directory contains the modular components of the AppFlowy Cloud diagnostic tool. The original monolithic script has been refactored into separate, maintainable modules.\n\n## Architecture Overview\n\n```\nscript/\n├── diagnose_appflowy.sh          # Main orchestration script\n└── lib/                           # Module library\n    ├── utils.sh                   # Utility functions\n    ├── check_containers.sh        # Container checks\n    ├── check_config.sh            # Configuration validation\n    ├── check_health.sh            # Health endpoint checks\n    ├── check_functional.sh        # Functional tests\n    ├── check_logs.sh              # Log analysis\n    └── report.sh                  # Report generation\n```\n\n## Module Descriptions\n\n### utils.sh (5.0K)\n**Purpose:** Core utility functions used across all modules\n\n**Functions:**\n- `print_header()` - Formatted header output\n- `print_success()` - Success message with green checkmark\n- `print_warning()` - Warning message with yellow icon\n- `print_error()` - Error message with red X\n- `print_info()` - Informational message with blue icon\n- `print_verbose()` - Verbose output (only shown with -v flag)\n- `mask_sensitive()` - Mask sensitive data in output\n- `show_help()` - Display help message\n- `parse_arguments()` - Parse command-line arguments\n- `load_env_vars()` - Load environment variables from .env\n- `extract_url_scheme()` - Parse URL scheme (http/https)\n- `extract_url_host()` - Parse URL host\n- `extract_url_path()` - Parse URL path\n\n### check_containers.sh (7.7K)\n**Purpose:** Docker and container-related checks\n\n**Functions:**\n- `check_docker()` - Verify Docker installation and daemon status\n- `check_docker_compose()` - Verify Docker Compose availability\n- `detect_compose_file()` - Auto-detect docker-compose.yml file\n- `check_env_file()` - Verify .env file exists\n- `get_compose_command()` - Get cached Docker Compose command\n- `check_container_status()` - Check status of all containers\n- `check_service_versions()` - Extract service version information\n\n### check_config.sh (42K)\n**Purpose:** Configuration validation and verification\n\n**Functions:**\n- `check_duplicate_env_keys()` - Find duplicate environment keys\n- `check_legacy_env_vars()` - Detect deprecated AF_* variables\n- `check_jwt_secrets()` - Validate JWT secret configuration\n- `check_database_urls()` - Verify database connection strings\n- `check_base_urls()` - Validate base URL configuration\n- `check_scheme_consistency()` - Check HTTP/HTTPS consistency\n- `check_gotrue_configuration()` - Validate GoTrue settings\n- `check_user_auth_flow()` - Check authentication flow settings\n- `check_admin_credentials()` - Verify admin credentials\n- `check_smtp_configuration()` - Validate SMTP settings\n- `check_ai_server_configuration()` - Verify AI server settings\n- `check_nginx_websocket_config()` - Check WebSocket proxy config\n- `check_ssl_certificate()` - Validate SSL certificates\n- `check_production_https_websocket()` - Check HTTPS/WSS for production\n- `check_websocket_cors_headers()` - Verify CORS headers\n- `check_plan_limits()` - Check plan and resource limits\n- `check_url_scheme_alignment()` - Verify URL scheme consistency\n- `check_websocket_url()` - Validate WebSocket URLs\n\n### check_health.sh (3.8K)\n**Purpose:** Health endpoint monitoring\n\n**Functions:**\n- `check_health_endpoint()` - Check a single health endpoint\n- `check_health_endpoints()` - Check all service health endpoints\n\n### check_functional.sh (18K)\n**Purpose:** Functional tests and API validation\n\n**Functions:**\n- `check_minio_storage()` - Test S3/Minio storage functionality\n- `check_database_tables()` - Verify database schema\n- `check_api_endpoints()` - Test API endpoint accessibility\n- `check_websocket_endpoint()` - Test WebSocket connectivity\n- `check_admin_frontend()` - Verify admin frontend accessibility\n- `check_collaboration_data()` - Test collaboration features\n- `check_ai_service()` - Test AI service functionality\n- `check_published_features()` - Check published/public features\n- `check_websocket_connection_simulation()` - Simulate WebSocket connection\n- `run_functional_tests()` - Orchestrate all functional tests\n\n### check_logs.sh (19K)\n**Purpose:** Log analysis and error detection\n\n**Functions:**\n- `check_admin_frontend_connectivity()` - Analyze admin frontend logs\n- `check_admin_frontend_errors()` - Find admin frontend errors\n- `check_gotrue_auth_errors()` - Find GoTrue authentication errors\n- `check_container_errors()` - Scan all containers for errors\n- `extract_container_crash_summary()` - Summarize container crashes\n- `analyze_service_logs()` - Deep analysis of service logs\n\n### report.sh (9.1K)\n**Purpose:** Report generation and recommendations\n\n**Functions:**\n- `generate_report()` - Create diagnostic report file\n- `generate_recommendations()` - Generate actionable recommendations\n\n## Usage\n\n### Running the Main Script\n```bash\n# Basic diagnostic\n./script/diagnose_appflowy.sh\n\n# Verbose with logs\n./script/diagnose_appflowy.sh -v -l\n\n# Quick check (skip slow tests)\n./script/diagnose_appflowy.sh --quick\n\n# Custom compose file\n./script/diagnose_appflowy.sh -f docker-compose-dev.yml\n```\n\n### Using Individual Modules\nModules can be sourced independently for custom diagnostic scripts:\n\n```bash\n#!/bin/bash\n\n# Source only what you need\nsource script/lib/utils.sh\nsource script/lib/check_containers.sh\n\n# Use the functions\ncheck_docker\ncheck_container_status\n```\n\n## Adding New Checks\n\n### 1. Add Function to Appropriate Module\n```bash\n# In script/lib/check_config.sh\ncheck_my_new_feature() {\n    print_verbose \"Checking my new feature...\"\n\n    # Your check logic here\n    if [[ condition ]]; then\n        print_success \"Feature OK\"\n        return 0\n    else\n        print_error \"Feature failed\"\n        return 1\n    fi\n}\n```\n\n### 2. Call Function in Main Script\n```bash\n# In script/diagnose_appflowy.sh main() function\ncheck_my_new_feature\n```\n\n## Backup\n\nThe original monolithic script is preserved as:\n```\nscript/diagnose_appflowy.sh.backup\n```\n\n## Benefits of Modular Architecture\n\n1. **Maintainability**: Each module has a clear, focused purpose\n2. **Testability**: Modules can be tested independently\n3. **Reusability**: Functions can be used in other scripts\n4. **Readability**: Easier to navigate and understand\n5. **Extensibility**: New checks can be added without affecting existing code\n6. **Collaboration**: Multiple developers can work on different modules\n\n## Version\n\nCurrent version: 2.0.0 (Modular architecture)\nPrevious version: 1.0.0 (Monolithic)\n\n## Contributing\n\nWhen contributing new checks:\n1. Place them in the appropriate module\n2. Follow the existing code style\n3. Use the print_* functions for output\n4. Document the function in this README\n5. Test the module independently before integration\n"
  },
  {
    "path": "script/lib/check_config.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Configuration Validation\n# =============================================================================\n# This module handles configuration validation including:\n# - URL scheme alignment and consistency checks\n# - WebSocket configuration validation\n# - Environment variable duplicate detection\n# - Legacy environment variable detection\n# - GoTrue service configuration\n# - JWT secret validation\n# - Database URL validation\n# - Base URL configuration\n# - Admin credentials verification\n# - SMTP configuration validation\n# - AI server configuration\n# - Nginx WebSocket configuration\n# - SSL/TLS certificate verification\n# - Production HTTPS/WSS validation\n# - WebSocket CORS and security headers\n# - Plan limits validation\n# - User authentication flow validation\n# =============================================================================\n\n# ==================== URL VALIDATION ====================\n\ncheck_url_scheme_alignment() {\n    local label=\"$1\"\n    local url=\"$2\"\n\n    local expected_scheme\n    expected_scheme=$(extract_url_scheme \"$url\")\n\n    local host\n    host=$(extract_url_host \"$url\")\n\n    if [[ -z \"$expected_scheme\" ]]; then\n        print_warning \"$label: URL is missing http/https scheme ($url)\"\n        return 1\n    fi\n\n    local curl_output\n    if ! curl_output=$(curl -sS -o /dev/null -D - --max-time 5 \"$url\" 2>&1); then\n        local error_reason=$(echo \"$curl_output\" | tail -n 1)\n        if echo \"$error_reason\" | grep -qi \"Could not resolve host\"; then\n            if [[ -n \"$host\" && \"$host\" != \"localhost\" && \"$host\" != \"127.0.0.1\" && \"$host\" != \"::1\" && \"$host\" != \"[::1]\" && \"$host\" != *.* ]]; then\n                print_info \"$label: Host '$host' is not reachable from the local host (this is expected for internal docker hostnames)\"\n                return 0\n            fi\n        fi\n        if [[ \"$expected_scheme\" == \"https\" ]]; then\n            print_warning \"$label: HTTPS request failed (${error_reason:-curl error})\"\n            print_warning \"  Fix: Ensure TLS is configured or update the URL to http:// if TLS is disabled\"\n        else\n            print_warning \"$label: HTTP request failed (${error_reason:-curl error})\"\n            print_warning \"  Fix: Confirm nginx listens on http:// or update the URL to https:// if TLS is enforced\"\n        fi\n        return 1\n    fi\n\n    local status_code=$(echo \"$curl_output\" | head -n 1 | awk '{print $2}')\n    local location_header=$(echo \"$curl_output\" | tr -d '\\r' | grep -i '^Location:' | tail -n 1 | awk '{print $2}')\n\n    if [[ -n \"$location_header\" ]]; then\n        local redirect_scheme\n        redirect_scheme=$(extract_url_scheme \"$location_header\")\n\n        if [[ -n \"$redirect_scheme\" && \"$redirect_scheme\" != \"$expected_scheme\" ]]; then\n            print_warning \"$label: Configured $expected_scheme:// but server redirects to $redirect_scheme:// ($status_code -> $location_header)\"\n            print_warning \"  Fix: Update the URL scheme in .env to match the redirect target\"\n            return 1\n        fi\n    fi\n\n    local scheme_upper\n    scheme_upper=$(printf '%s' \"$expected_scheme\" | tr '[:lower:]' '[:upper:]')\n    print_verbose \"$label: ${scheme_upper:-UNKNOWN} endpoint reachable (HTTP ${status_code:-unknown})\"\n    return 0\n}\n\ncheck_websocket_url() {\n    local label=\"$1\"\n    local ws_url=\"$2\"\n    local base_url=\"$3\"\n\n    local ws_scheme\n    ws_scheme=$(extract_url_scheme \"$ws_url\")\n\n    if [[ -z \"$ws_scheme\" ]]; then\n        print_warning \"$label: Missing ws/wss scheme ($ws_url)\"\n        return 1\n    fi\n\n    if [[ \"$ws_scheme\" == \"http\" || \"$ws_scheme\" == \"https\" ]]; then\n        print_error \"$label: Expected ws:// or wss:// but found $ws_scheme://\"\n        print_error \"  Fix: Update the WebSocket URL to use ws/wss\"\n        return 1\n    fi\n\n    local path\n    path=$(extract_url_path \"$ws_url\")\n\n    if [[ -z \"$path\" ]]; then\n        print_warning \"$label: URL has no path; expected /ws or /ws/v1\"\n    elif [[ \"$path\" != /ws* ]]; then\n        print_warning \"$label: Unexpected WebSocket path '$path' (expected to start with /ws)\"\n    fi\n\n    if [[ -n \"$base_url\" ]]; then\n        local base_scheme\n        base_scheme=$(extract_url_scheme \"$base_url\")\n        local ws_host\n        ws_host=$(extract_url_host \"$ws_url\")\n\n        if [[ \"$base_scheme\" == \"https\" && \"$ws_scheme\" != \"wss\" ]]; then\n            # Only error for non-localhost deployments\n            if [[ \"$ws_host\" != \"localhost\" && \"$ws_host\" != \"127.0.0.1\" ]]; then\n                print_error \"$label: Base URL uses https but WebSocket URL is $ws_scheme:// (CRITICAL)\"\n                print_error \"  Fix: Use wss:// when the site is served over https\"\n                print_error \"  Update .env: APPFLOWY_WS_BASE_URL=wss://yourdomain.com/ws/v2\"\n                print_error \"  Or update .env: WS_SCHEME=wss (if using variable substitution)\"\n                return 1\n            else\n                print_verbose \"$label: Localhost deployment - scheme mismatch acceptable for local testing\"\n            fi\n        elif [[ \"$base_scheme\" == \"http\" && \"$ws_scheme\" != \"ws\" ]]; then\n            print_warning \"$label: Base URL uses http but WebSocket URL is $ws_scheme://\"\n            print_warning \"  Fix: Use ws:// when the site is served over http\"\n        fi\n    fi\n\n    return 0\n}\n\ncheck_scheme_consistency() {\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local scheme=\"${SCHEME}\"\n    if [[ -z \"$scheme\" ]]; then\n        print_verbose \"SCHEME variable not set (defaults depend on compose file)\"\n        return 0\n    fi\n\n    local scheme_lower=$(echo \"$scheme\" | tr '[:upper:]' '[:lower:]')\n    if [[ \"$scheme_lower\" != \"http\" && \"$scheme_lower\" != \"https\" ]]; then\n        print_warning \"SCHEME has unexpected value '$scheme' (expected http or https)\"\n        return 1\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local base_scheme\n    base_scheme=$(extract_url_scheme \"$base_url\")\n\n    if [[ -n \"$base_scheme\" ]]; then\n        if [[ \"$base_scheme\" != \"$scheme_lower\" ]]; then\n            print_warning \"SCHEME=$scheme differs from APPFLOWY_BASE_URL scheme ($base_scheme)\"\n            print_warning \"  Fix: Align SCHEME with the protocol actually served by nginx\"\n        else\n            print_verbose \"SCHEME aligns with APPFLOWY_BASE_URL\"\n        fi\n    else\n        print_verbose \"SCHEME is set but APPFLOWY_BASE_URL scheme could not be determined\"\n    fi\n\n    return 0\n}\n\n# ==================== ENVIRONMENT VARIABLE VALIDATION ====================\n\ncheck_duplicate_env_keys() {\n    local keys=(\n        \"APPFLOWY_BASE_URL\"\n        \"API_EXTERNAL_URL\"\n        \"APPFLOWY_GOTRUE_BASE_URL\"\n        \"APPFLOWY_WS_BASE_URL\"\n        \"APPFLOWY_WEBSOCKET_BASE_URL\"\n        \"SCHEME\"\n    )\n\n    if [[ ! -f \"$PROJECT_ROOT/.env\" ]]; then\n        return 0\n    fi\n\n    for key in \"${keys[@]}\"; do\n        local matches\n        matches=$(grep -n \"^${key}=\" \"$PROJECT_ROOT/.env\" || true)\n        if [[ -n \"$matches\" ]]; then\n            local count\n            count=$(echo \"$matches\" | wc -l | tr -d ' ')\n            if [[ \"$count\" -gt 1 ]]; then\n                print_warning \"${key} defined $count times in .env (duplicate definitions detected)\"\n                if [[ \"$VERBOSE\" == \"true\" ]]; then\n                    print_verbose \"  Lines: $(echo \"$matches\" | cut -d: -f1 | tr '\\n' ' ')\"\n                fi\n            fi\n        fi\n    done\n}\n\ncheck_legacy_env_vars() {\n    if [[ ! -f \"$PROJECT_ROOT/.env\" ]]; then\n        return 0\n    fi\n\n    local legacy\n    legacy=$(grep -E '^AF_[A-Z0-9_]+=' \"$PROJECT_ROOT/.env\" || true)\n    if [[ -n \"$legacy\" ]]; then\n        print_warning \"Detected deprecated AF_* environment variables; update to APPFLOWY_* equivalents\"\n        if [[ \"$VERBOSE\" == \"true\" ]]; then\n            while IFS= read -r line; do\n                print_verbose \"  $line\"\n            done <<< \"$legacy\"\n        fi\n    fi\n}\n\n# ==================== SERVICE CONFIGURATION VALIDATION ====================\n\ncheck_gotrue_configuration() {\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local site_url=\"${GOTRUE_SITE_URL}\"\n    local uri_allow_list=\"${GOTRUE_URI_ALLOW_LIST}\"\n\n    if [[ -z \"$site_url\" ]]; then\n        print_info \"GOTRUE_SITE_URL is not set (docker-compose defaults to appflowy-flutter:// for desktop/mobile deep links)\"\n    elif [[ -n \"$base_url\" ]]; then\n        local base_scheme\n        base_scheme=$(extract_url_scheme \"$base_url\")\n        local site_scheme\n        site_scheme=$(extract_url_scheme \"$site_url\")\n        if [[ \"$site_scheme\" == \"appflowy-flutter\" ]]; then\n            print_verbose \"GOTRUE_SITE_URL configured for desktop/mobile deep link ($site_url)\"\n        else\n            if [[ -n \"$base_scheme\" && -n \"$site_scheme\" && \"$base_scheme\" != \"$site_scheme\" ]]; then\n                print_warning \"GOTRUE_SITE_URL scheme ($site_scheme) differs from APPFLOWY_BASE_URL ($base_scheme)\"\n            fi\n            if [[ $site_url != ${base_url}* ]]; then\n                print_warning \"GOTRUE_SITE_URL ($site_url) does not start with APPFLOWY_BASE_URL ($base_url)\"\n            fi\n        fi\n    fi\n\n    if [[ -n \"$uri_allow_list\" ]]; then\n        if [[ \"$uri_allow_list\" != *\"appflowy-flutter://\"* ]]; then\n            print_warning \"GOTRUE_URI_ALLOW_LIST missing appflowy-flutter:// callbacks\"\n        fi\n        if [[ -n \"$base_url\" && \"$uri_allow_list\" != *\"$base_url\"* ]]; then\n            print_warning \"GOTRUE_URI_ALLOW_LIST does not include $base_url\"\n        fi\n    else\n        print_info \"GOTRUE_URI_ALLOW_LIST is not set (optional, but required for desktop/mobile deep links and OAuth callbacks)\"\n    fi\n\n    return 0\n}\n\ncheck_jwt_secrets() {\n    print_verbose \"Validating JWT secret configuration...\"\n\n    if ! load_env_vars; then\n        print_error \"Cannot load .env file\"\n        return 1\n    fi\n\n    local gotrue_secret=\"${GOTRUE_JWT_SECRET}\"\n    local appflowy_secret=\"${APPFLOWY_GOTRUE_JWT_SECRET}\"\n\n    # Source code analysis (src/config/config.rs line 213):\n    # - APPFLOWY_GOTRUE_JWT_SECRET: Optional, defaults to \"hello456\" in AppFlowy Cloud code\n    # Docker compose analysis (line 117):\n    # - APPFLOWY_GOTRUE_JWT_SECRET=${GOTRUE_JWT_SECRET} in container\n    # This means:\n    # 1. .env only needs GOTRUE_JWT_SECRET\n    # 2. Docker compose auto-sets APPFLOWY_GOTRUE_JWT_SECRET from GOTRUE_JWT_SECRET\n    # 3. If neither is set, AppFlowy Cloud uses default \"hello456\"\n\n    # Determine effective AppFlowy secret\n    if [[ -z \"$appflowy_secret\" ]]; then\n        # If APPFLOWY_GOTRUE_JWT_SECRET not in .env, docker-compose sets it from GOTRUE_JWT_SECRET\n        # If that's also empty, code defaults to \"hello456\"\n        if [[ -n \"$gotrue_secret\" ]]; then\n            appflowy_secret=\"$gotrue_secret\"\n            print_verbose \"APPFLOWY_GOTRUE_JWT_SECRET not set - will use GOTRUE_JWT_SECRET via docker-compose\"\n        else\n            appflowy_secret=\"hello456\"\n            print_info \"JWT secrets not configured - using default: $(mask_sensitive \"$appflowy_secret\")\"\n            print_info \"  Recommended: Set GOTRUE_JWT_SECRET in .env for production\"\n        fi\n    fi\n\n    # GOTRUE_JWT_SECRET is required for GoTrue container (line 69 docker-compose.yml)\n    if [[ -z \"$gotrue_secret\" ]]; then\n        print_error \"GOTRUE_JWT_SECRET is not set in .env (REQUIRED)\"\n        print_error \"  GoTrue container requires this for authentication\"\n        print_error \"  Fix: Add to .env file:\"\n        print_error \"    GOTRUE_JWT_SECRET=$appflowy_secret\"\n        return 1\n    fi\n\n    # Check if they match\n    if [[ \"$gotrue_secret\" != \"$appflowy_secret\" ]]; then\n        print_error \"JWT secrets do not match (CRITICAL)\"\n        print_error \"  GOTRUE_JWT_SECRET: $(mask_sensitive \"$gotrue_secret\")\"\n        print_error \"  APPFLOWY_GOTRUE_JWT_SECRET: $(mask_sensitive \"$appflowy_secret\")\"\n        print_error \"  Fix: Ensure both match in .env:\"\n        print_error \"    GOTRUE_JWT_SECRET=$gotrue_secret\"\n        print_error \"    # APPFLOWY_GOTRUE_JWT_SECRET not needed (auto-set from GOTRUE_JWT_SECRET)\"\n        return 1\n    fi\n\n    print_success \"JWT secrets match: $(mask_sensitive \"$gotrue_secret\")\"\n    return 0\n}\n\ncheck_database_urls() {\n    print_verbose \"Validating database URL configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local gotrue_db=\"${GOTRUE_DATABASE_URL}\"\n    local appflowy_db=\"${APPFLOWY_DATABASE_URL}\"\n\n    if [[ -z \"$gotrue_db\" ]]; then\n        print_error \"GOTRUE_DATABASE_URL is not set\"\n        return 1\n    fi\n\n    if [[ -z \"$appflowy_db\" ]]; then\n        print_error \"APPFLOWY_DATABASE_URL is not set\"\n        return 1\n    fi\n\n    # Extract database host from URLs\n    local gotrue_host=$(echo \"$gotrue_db\" | sed -n 's|.*@\\([^:/]*\\).*|\\1|p')\n    local appflowy_host=$(echo \"$appflowy_db\" | sed -n 's|.*@\\([^:/]*\\).*|\\1|p')\n\n    if [[ \"$gotrue_host\" != \"$appflowy_host\" ]]; then\n        print_warning \"Database hosts differ: GoTrue($gotrue_host) vs AppFlowy($appflowy_host)\"\n    else\n        print_success \"Database hosts match: $gotrue_host\"\n    fi\n\n    print_verbose \"GOTRUE_DATABASE_URL: $(echo $gotrue_db | sed 's|://.*@|://***:***@|')\"\n    print_verbose \"APPFLOWY_DATABASE_URL: $(echo $appflowy_db | sed 's|://.*@|://***:***@|')\"\n\n    return 0\n}\n\ncheck_base_urls() {\n    print_verbose \"Validating base URL configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local api_external=\"${API_EXTERNAL_URL}\"\n    local gotrue_base=\"${APPFLOWY_GOTRUE_BASE_URL}\"\n    local ws_url_primary=\"${APPFLOWY_WS_BASE_URL}\"\n    local ws_url_legacy=\"${APPFLOWY_WEBSOCKET_BASE_URL}\"\n    local effective_ws_url=\"${ws_url_primary:-$ws_url_legacy}\"\n    local web_url=\"${APPFLOWY_WEB_URL}\"\n\n    # APPFLOWY_WEB_URL is REQUIRED (src/config/config.rs line 274-275)\n    # It's the only variable without a default that causes startup failure\n    if [[ -z \"$web_url\" ]]; then\n        print_error \"APPFLOWY_WEB_URL is not set (REQUIRED)\"\n        print_error \"  AppFlowy Cloud will fail to start without this\"\n        print_error \"  Fix: Add to .env file:\"\n        print_error \"    APPFLOWY_WEB_URL=\\${APPFLOWY_BASE_URL}\"\n        print_error \"  For custom web deployment:\"\n        print_error \"    APPFLOWY_WEB_URL=https://your-web-domain.com\"\n        return 1\n    else\n        print_success \"APPFLOWY_WEB_URL: $web_url\"\n    fi\n\n    # APPFLOWY_BASE_URL has default in code, but typically set in .env for production\n    if [[ -z \"$base_url\" ]]; then\n        print_info \"APPFLOWY_BASE_URL not set - check docker-compose for required services\"\n    else\n        print_success \"APPFLOWY_BASE_URL: $base_url\"\n        check_url_scheme_alignment \"APPFLOWY_BASE_URL\" \"$base_url\"\n    fi\n\n    # API_EXTERNAL_URL typically derived from APPFLOWY_BASE_URL in .env\n    if [[ -z \"$api_external\" ]]; then\n        print_verbose \"API_EXTERNAL_URL not set (usually set via .env template)\"\n    else\n        print_success \"API_EXTERNAL_URL: $api_external\"\n        check_url_scheme_alignment \"API_EXTERNAL_URL\" \"$api_external\"\n    fi\n\n    # APPFLOWY_GOTRUE_BASE_URL has default \"http://localhost:9999\" (src/config/config.rs line 212)\n    if [[ -z \"$gotrue_base\" ]]; then\n        print_verbose \"APPFLOWY_GOTRUE_BASE_URL not set - using default: http://localhost:9999\"\n    else\n        print_success \"APPFLOWY_GOTRUE_BASE_URL: $gotrue_base\"\n        check_url_scheme_alignment \"APPFLOWY_GOTRUE_BASE_URL\" \"$gotrue_base\"\n        local gotrue_scheme\n        gotrue_scheme=$(extract_url_scheme \"$gotrue_base\")\n        local gotrue_host\n        gotrue_host=$(extract_url_host \"$gotrue_base\")\n        if [[ -n \"$gotrue_host\" && \"$gotrue_host\" != *.* && \"$gotrue_scheme\" == \"https\" ]]; then\n            print_warning \"APPFLOWY_GOTRUE_BASE_URL uses https with internal host '$gotrue_host'\"\n            print_warning \"  Fix: Use http://gotrue:9999 when referencing the container internally\"\n        fi\n    fi\n\n    if [[ -n \"$ws_url_primary\" && -n \"$ws_url_legacy\" ]]; then\n        print_warning \"Both APPFLOWY_WS_BASE_URL and APPFLOWY_WEBSOCKET_BASE_URL are set; APPFLOWY_WS_BASE_URL takes precedence\"\n    fi\n\n    if [[ -n \"$effective_ws_url\" ]]; then\n        local ws_label\n        if [[ -n \"$ws_url_primary\" ]]; then\n            ws_label=\"APPFLOWY_WS_BASE_URL\"\n        else\n            ws_label=\"APPFLOWY_WEBSOCKET_BASE_URL\"\n        fi\n        print_success \"$ws_label: $effective_ws_url\"\n        check_websocket_url \"$ws_label\" \"$effective_ws_url\" \"$base_url\"\n    else\n        print_warning \"WebSocket base URL not set (APPFLOWY_WS_BASE_URL or APPFLOWY_WEBSOCKET_BASE_URL)\"\n    fi\n\n    return 0\n}\n\ncheck_admin_credentials() {\n    print_verbose \"Checking admin credentials configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local admin_email=\"${GOTRUE_ADMIN_EMAIL}\"\n    local admin_password=\"${GOTRUE_ADMIN_PASSWORD}\"\n\n    if [[ -z \"$admin_email\" ]]; then\n        print_warning \"GOTRUE_ADMIN_EMAIL is not set\"\n    else\n        print_success \"Admin email configured: $admin_email\"\n    fi\n\n    if [[ -z \"$admin_password\" ]]; then\n        print_warning \"GOTRUE_ADMIN_PASSWORD is not set\"\n    else\n        print_success \"Admin password configured: $(mask_sensitive \"$admin_password\")\"\n    fi\n\n    return 0\n}\n\ncheck_smtp_configuration() {\n    print_verbose \"Checking SMTP configuration for emails...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local gotrue_smtp_host=\"${GOTRUE_SMTP_HOST}\"\n    local gotrue_smtp_port=\"${GOTRUE_SMTP_PORT}\"\n    local gotrue_smtp_user=\"${GOTRUE_SMTP_USER}\"\n\n    local appflowy_smtp_host=\"${APPFLOWY_MAILER_SMTP_HOST}\"\n    local appflowy_smtp_port=\"${APPFLOWY_MAILER_SMTP_PORT}\"\n    local appflowy_smtp_username=\"${APPFLOWY_MAILER_SMTP_USERNAME}\"\n    local appflowy_smtp_email=\"${APPFLOWY_MAILER_SMTP_EMAIL}\"\n\n    local has_gotrue_smtp=false\n    local has_appflowy_smtp=false\n    local has_issues=false\n\n    # Check if GOTRUE SMTP is configured\n    if [[ -n \"$gotrue_smtp_host\" && -n \"$gotrue_smtp_port\" ]]; then\n        has_gotrue_smtp=true\n        print_success \"GOTRUE SMTP configured: $gotrue_smtp_host:$gotrue_smtp_port\"\n        print_verbose \"  Used for: Authentication emails (signup, password reset, magic links)\"\n    fi\n\n    # Check if APPFLOWY MAILER SMTP is configured\n    if [[ -n \"$appflowy_smtp_host\" && -n \"$appflowy_smtp_port\" ]]; then\n        has_appflowy_smtp=true\n        print_success \"APPFLOWY_MAILER SMTP configured: $appflowy_smtp_host:$appflowy_smtp_port\"\n        print_verbose \"  Used for: Workspace invitations, sharing notifications\"\n    fi\n\n    # Critical check: GOTRUE configured but APPFLOWY not configured\n    if [[ \"$has_gotrue_smtp\" == \"true\" && \"$has_appflowy_smtp\" == \"false\" ]]; then\n        print_error \"SMTP Configuration Incomplete (CRITICAL for workspace sharing)\"\n        print_error \"  Issue: GOTRUE_SMTP is configured but APPFLOWY_MAILER_SMTP is not\"\n        print_error \"  Impact: Users CANNOT share workspaces or send invitations\"\n        print_error \"  GOTRUE_SMTP is only for auth emails (signup, password reset)\"\n        print_error \"  APPFLOWY_MAILER_SMTP is required for workspace invitations\"\n        print_error \"\"\n        print_error \"  Fix: Add these to .env file:\"\n        print_error \"    APPFLOWY_MAILER_SMTP_HOST=$gotrue_smtp_host\"\n        print_error \"    APPFLOWY_MAILER_SMTP_PORT=$gotrue_smtp_port\"\n        if [[ -n \"$gotrue_smtp_user\" ]]; then\n            print_error \"    APPFLOWY_MAILER_SMTP_USERNAME=$gotrue_smtp_user\"\n            print_error \"    APPFLOWY_MAILER_SMTP_EMAIL=$gotrue_smtp_user\"\n        fi\n        print_error \"    APPFLOWY_MAILER_SMTP_PASSWORD=<your_smtp_password>\"\n        print_error \"    APPFLOWY_MAILER_SMTP_TLS_KIND=wrapper  # or 'required'\"\n        print_error \"\"\n        print_error \"  Then restart: docker compose restart appflowy_cloud\"\n        has_issues=true\n    fi\n\n    # Warning: Neither configured\n    if [[ \"$has_gotrue_smtp\" == \"false\" && \"$has_appflowy_smtp\" == \"false\" ]]; then\n        print_info \"SMTP: Not configured (email features disabled)\"\n        print_info \"  Without SMTP, the following features won't work:\"\n        print_info \"    - Workspace invitations (users cannot share workspaces)\"\n        print_info \"    - Email notifications\"\n        print_info \"    - Password reset emails (if GOTRUE_MAILER_AUTOCONFIRM=false)\"\n        print_info \"  To enable email features, configure both:\"\n        print_info \"    - GOTRUE_SMTP_* (for authentication emails)\"\n        print_info \"    - APPFLOWY_MAILER_SMTP_* (for workspace invitations)\"\n    fi\n\n    # Success: Both configured\n    if [[ \"$has_gotrue_smtp\" == \"true\" && \"$has_appflowy_smtp\" == \"true\" ]]; then\n        print_success \"SMTP: Fully configured for all email features\"\n\n        # Check if credentials are set\n        if [[ -z \"$appflowy_smtp_username\" || -z \"$appflowy_smtp_email\" ]]; then\n            print_warning \"APPFLOWY_MAILER_SMTP_USERNAME or APPFLOWY_MAILER_SMTP_EMAIL not set\"\n        fi\n    fi\n\n    # Additional validation: Check TLS kind\n    local tls_kind=\"${APPFLOWY_MAILER_SMTP_TLS_KIND}\"\n    if [[ \"$has_appflowy_smtp\" == \"true\" ]]; then\n        if [[ -z \"$tls_kind\" ]]; then\n            print_warning \"APPFLOWY_MAILER_SMTP_TLS_KIND not set (defaults may not work)\"\n            print_warning \"  Recommended values: 'wrapper' (port 465), 'required' (port 587)\"\n        else\n            print_verbose \"TLS Kind: $tls_kind\"\n        fi\n    fi\n\n    if [[ \"$has_issues\" == \"true\" ]]; then\n        return 1\n    fi\n\n    return 0\n}\n\ncheck_ai_server_configuration() {\n    print_verbose \"Checking AI server configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local ai_server_host=\"${AI_SERVER_HOST}\"\n    local ai_server_port=\"${AI_SERVER_PORT}\"\n    local appflowy_ai_version=\"${APPFLOWY_AI_VERSION}\"\n\n    # Check if AI server is configured\n    if [[ -z \"$ai_server_host\" && -z \"$ai_server_port\" ]]; then\n        print_info \"AI Server: Not configured (AI features disabled)\"\n        print_info \"  Without AI server, the following features won't work:\"\n        print_info \"    - AI-powered chat and assistance\"\n        print_info \"    - Document AI completions\"\n        print_info \"    - Smart search and indexing\"\n        print_info \"  To enable AI features, add to .env file:\"\n        print_info \"    AI_SERVER_HOST=ai  # or 'localhost' for local development\"\n        print_info \"    AI_SERVER_PORT=5001\"\n        print_info \"    APPFLOWY_AI_VERSION=latest  # or specific version\"\n        return 0\n    fi\n\n    # Default values if only partially configured\n    ai_server_host=\"${ai_server_host:-localhost}\"\n    ai_server_port=\"${ai_server_port:-5001}\"\n\n    print_success \"AI Server configured: $ai_server_host:$ai_server_port\"\n    print_verbose \"  Used for: AI chat, completions, embeddings, and smart search\"\n\n    # Check AI version if specified\n    if [[ -n \"$appflowy_ai_version\" ]]; then\n        print_verbose \"  AI Server Version: $appflowy_ai_version\"\n    else\n        print_verbose \"  AI Server Version: latest (default)\"\n    fi\n\n    # Check if AI container is running (if using docker-compose)\n    local compose_cmd=$(get_compose_command 2>/dev/null)\n    if [[ -n \"$compose_cmd\" ]]; then\n        # Check if 'ai' service is defined in compose file\n        if $compose_cmd config --services 2>/dev/null | grep -q \"^ai$\"; then\n            local ai_container=$($compose_cmd ps -q ai 2>/dev/null)\n            if [[ -n \"$ai_container\" ]]; then\n                local container_status=$(docker inspect --format='{{.State.Status}}' $ai_container 2>/dev/null)\n                if [[ \"$container_status\" == \"running\" ]]; then\n                    print_success \"AI container: Running\"\n                else\n                    print_warning \"AI container exists but is not running (status: $container_status)\"\n                    print_warning \"  Fix: docker compose restart ai\"\n                fi\n            else\n                print_warning \"AI service defined but container not found\"\n                print_warning \"  Fix: docker compose up -d ai\"\n            fi\n        else\n            print_verbose \"AI service not defined in docker-compose.yml (may be external)\"\n        fi\n    fi\n\n    # Validate host/port make sense\n    if [[ \"$ai_server_host\" == \"localhost\" || \"$ai_server_host\" == \"127.0.0.1\" ]]; then\n        print_verbose \"  AI server configured for local development\"\n    elif [[ \"$ai_server_host\" == \"ai\" ]]; then\n        print_verbose \"  AI server configured for docker-compose deployment\"\n    else\n        print_info \"  AI server configured for external deployment: $ai_server_host\"\n    fi\n\n    return 0\n}\n\n# ==================== NGINX CONFIGURATION VALIDATION ====================\n\ncheck_nginx_websocket_config() {\n    print_verbose \"Checking nginx WebSocket configuration...\"\n\n    local nginx_conf_files=(\n        \"$PROJECT_ROOT/nginx/nginx.conf\"\n        \"$PROJECT_ROOT/nginx.conf\"\n        \"$PROJECT_ROOT/nginx/conf.d/default.conf\"\n    )\n\n    local nginx_conf=\"\"\n    for conf_file in \"${nginx_conf_files[@]}\"; do\n        if [[ -f \"$conf_file\" ]]; then\n            nginx_conf=\"$conf_file\"\n            break\n        fi\n    done\n\n    if [[ -z \"$nginx_conf\" ]]; then\n        print_warning \"Nginx config file not found (checked common locations)\"\n        return 1\n    fi\n\n    print_verbose \"Found nginx config: $nginx_conf\"\n\n    # Check for WebSocket upgrade headers in location blocks that proxy to AppFlowy\n    local has_ws_location=false\n    local has_upgrade_header=false\n    local has_connection_header=false\n    local has_http_version=false\n    local ws_location_found=false\n    local current_location=\"\"\n\n    # Look for location blocks that handle WebSocket (/ws or upstream appflowy_cloud)\n    while IFS= read -r line; do\n        # Detect location blocks for WebSocket paths\n        if echo \"$line\" | grep -E '^\\s*location\\s+.*(/ws|/api)' &>/dev/null; then\n            ws_location_found=true\n            current_location=$(echo \"$line\" | grep -oE 'location\\s+[^{]+' | sed 's/location //')\n            print_verbose \"Found WebSocket-related location: $current_location\"\n        fi\n\n        # Check for WebSocket upgrade headers within relevant location blocks\n        if [[ \"$ws_location_found\" == \"true\" ]]; then\n            if echo \"$line\" | grep -E '^\\s*proxy_set_header\\s+Upgrade\\s+\\$http_upgrade' &>/dev/null; then\n                has_upgrade_header=true\n                print_verbose \"  ✓ Found: proxy_set_header Upgrade \\$http_upgrade\"\n            fi\n\n            if echo \"$line\" | grep -iE '^\\s*proxy_set_header\\s+Connection\\s+.*(upgrade|connection_upgrade)' &>/dev/null; then\n                has_connection_header=true\n                print_verbose \"  ✓ Found: proxy_set_header Connection upgrade/\\$connection_upgrade\"\n            fi\n\n            if echo \"$line\" | grep -E '^\\s*proxy_http_version\\s+1\\.1' &>/dev/null; then\n                has_http_version=true\n                print_verbose \"  ✓ Found: proxy_http_version 1.1\"\n            fi\n\n            # Reset when location block ends\n            if echo \"$line\" | grep -E '^\\s*}' &>/dev/null; then\n                ws_location_found=false\n            fi\n        fi\n\n        # Also check for general upstream blocks\n        if echo \"$line\" | grep -E 'upstream\\s+(appflowy_cloud|appflowy)' &>/dev/null; then\n            has_ws_location=true\n        fi\n    done < \"$nginx_conf\"\n\n    # Validate results\n    local has_issues=false\n\n    if [[ \"$has_upgrade_header\" != \"true\" ]]; then\n        print_error \"Nginx: Missing WebSocket Upgrade header (CRITICAL for WSS)\"\n        print_error \"  Fix: Add to nginx WebSocket location block:\"\n        print_error \"    proxy_set_header Upgrade \\$http_upgrade;\"\n        has_issues=true\n    else\n        print_success \"Nginx: WebSocket Upgrade header configured\"\n    fi\n\n    if [[ \"$has_connection_header\" != \"true\" ]]; then\n        print_error \"Nginx: Missing WebSocket Connection header (CRITICAL for WSS)\"\n        print_error \"  Fix: Add to nginx WebSocket location block:\"\n        print_error \"    proxy_set_header Connection \\\"upgrade\\\";\"\n        has_issues=true\n    else\n        print_success \"Nginx: WebSocket Connection header configured\"\n    fi\n\n    if [[ \"$has_http_version\" != \"true\" ]]; then\n        print_error \"Nginx: Missing HTTP/1.1 version (CRITICAL for WSS)\"\n        print_error \"  Fix: Add to nginx WebSocket location block:\"\n        print_error \"    proxy_http_version 1.1;\"\n        has_issues=true\n    else\n        print_success \"Nginx: HTTP/1.1 version configured\"\n    fi\n\n    if [[ \"$has_issues\" == \"true\" ]]; then\n        print_error \"\"\n        print_error \"Example nginx WebSocket configuration:\"\n        print_error \"  location /ws {\"\n        print_error \"    proxy_pass http://appflowy_cloud:8000;\"\n        print_error \"    proxy_http_version 1.1;\"\n        print_error \"    proxy_set_header Upgrade \\$http_upgrade;\"\n        print_error \"    proxy_set_header Connection \\\"upgrade\\\";\"\n        print_error \"    proxy_set_header Host \\$host;\"\n        print_error \"    proxy_set_header X-Real-IP \\$remote_addr;\"\n        print_error \"  }\"\n        return 1\n    fi\n\n    return 0\n}\n\ncheck_ssl_certificate() {\n    print_verbose \"Checking SSL/TLS certificate configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local base_scheme\n    base_scheme=$(extract_url_scheme \"$base_url\")\n\n    # Only check SSL if using HTTPS\n    if [[ \"$base_scheme\" != \"https\" ]]; then\n        print_verbose \"SSL check skipped (not using HTTPS)\"\n        return 0\n    fi\n\n    local host\n    host=$(extract_url_host \"$base_url\")\n\n    if [[ -z \"$host\" || \"$host\" == \"localhost\" || \"$host\" == \"127.0.0.1\" ]]; then\n        print_verbose \"SSL check skipped (localhost deployment)\"\n        return 0\n    fi\n\n    print_verbose \"Checking SSL certificate for: $host\"\n\n    # Check if openssl is available\n    if ! command -v openssl &> /dev/null; then\n        print_warning \"SSL: openssl not found, cannot verify certificate\"\n        return 0\n    fi\n\n    # Try to get certificate info\n    local cert_info\n    cert_info=$(echo | openssl s_client -servername \"$host\" -connect \"${host}:443\" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null)\n\n    if [[ $? -ne 0 || -z \"$cert_info\" ]]; then\n        print_error \"SSL: Cannot retrieve certificate for $host\"\n        print_error \"  Fix: Ensure valid SSL/TLS certificate is installed\"\n        print_error \"  For Let's Encrypt: certbot certonly --standalone -d $host\"\n        return 1\n    fi\n\n    # Extract expiry date\n    local not_after\n    not_after=$(echo \"$cert_info\" | grep \"notAfter=\" | cut -d'=' -f2)\n\n    if [[ -n \"$not_after\" ]]; then\n        # Convert to epoch for comparison\n        local expiry_epoch\n        expiry_epoch=$(date -j -f \"%b %d %T %Y %Z\" \"$not_after\" +%s 2>/dev/null || date -d \"$not_after\" +%s 2>/dev/null)\n        local now_epoch\n        now_epoch=$(date +%s)\n\n        if [[ -n \"$expiry_epoch\" ]]; then\n            local days_until_expiry=$(( (expiry_epoch - now_epoch) / 86400 ))\n\n            if [[ $days_until_expiry -lt 0 ]]; then\n                print_error \"SSL: Certificate EXPIRED $((days_until_expiry * -1)) days ago\"\n                print_error \"  Fix: Renew SSL certificate immediately\"\n                return 1\n            elif [[ $days_until_expiry -lt 7 ]]; then\n                print_warning \"SSL: Certificate expires in $days_until_expiry days\"\n                print_warning \"  Fix: Renew SSL certificate soon\"\n            elif [[ $days_until_expiry -lt 30 ]]; then\n                print_warning \"SSL: Certificate expires in $days_until_expiry days\"\n            else\n                print_success \"SSL: Certificate valid (expires in $days_until_expiry days)\"\n            fi\n        fi\n    fi\n\n    # Test actual HTTPS connection\n    local https_test\n    https_test=$(curl -sS -o /dev/null -w \"%{http_code}\" --max-time 5 \"$base_url\" 2>&1)\n\n    if [[ $? -ne 0 ]]; then\n        if echo \"$https_test\" | grep -qi \"SSL certificate problem\"; then\n            print_error \"SSL: Certificate verification failed\"\n            print_error \"  Fix: Check certificate chain and CA certificates\"\n            return 1\n        elif echo \"$https_test\" | grep -qi \"SSL\"; then\n            print_error \"SSL: Connection failed - $https_test\"\n            return 1\n        fi\n    fi\n\n    return 0\n}\n\ncheck_production_https_websocket() {\n    print_verbose \"Checking production HTTPS/WSS configuration...\"\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local base_scheme\n    base_scheme=$(extract_url_scheme \"$base_url\")\n    local base_host\n    base_host=$(extract_url_host \"$base_url\")\n\n    # Only check for production deployments (non-localhost with HTTPS)\n    if [[ \"$base_scheme\" != \"https\" ]]; then\n        print_verbose \"Not using HTTPS - skipping production WSS check\"\n        return 0\n    fi\n\n    if [[ \"$base_host\" == \"localhost\" || \"$base_host\" == \"127.0.0.1\" ]]; then\n        print_verbose \"Localhost deployment - skipping production WSS check\"\n        return 0\n    fi\n\n    print_verbose \"Production HTTPS deployment detected: $base_url\"\n\n    # Check WebSocket URL scheme\n    local ws_url=\"${APPFLOWY_WS_BASE_URL:-${APPFLOWY_WEBSOCKET_BASE_URL}}\"\n    if [[ -z \"$ws_url\" ]]; then\n        print_error \"Production HTTPS: WebSocket URL not configured\"\n        print_error \"  Fix: Set APPFLOWY_WS_BASE_URL=wss://$base_host/ws/v2 in .env\"\n        return 1\n    fi\n\n    local ws_scheme\n    ws_scheme=$(extract_url_scheme \"$ws_url\")\n\n    if [[ \"$ws_scheme\" != \"wss\" ]]; then\n        print_error \"Production HTTPS: WebSocket URL is $ws_scheme:// but MUST be wss:// (CRITICAL)\"\n        print_error \"  Issue: Browsers block insecure WebSocket (ws://) connections from HTTPS pages\"\n        print_error \"  This causes: 'WebSocket connection failed' errors and app hangs after login\"\n        print_error \"  Fix: Update .env file:\"\n        print_error \"    APPFLOWY_WS_BASE_URL=wss://$base_host/ws/v2\"\n        print_error \"  Or if using WS_SCHEME variable:\"\n        print_error \"    WS_SCHEME=wss\"\n        print_error \"  Then restart: docker compose restart appflowy_cloud nginx\"\n        return 1\n    fi\n\n    print_success \"Production HTTPS: WebSocket correctly configured with wss://\"\n\n    # Verify nginx SSL is configured\n    local nginx_conf_files=(\n        \"$PROJECT_ROOT/nginx/nginx.conf\"\n        \"$PROJECT_ROOT/nginx.conf\"\n    )\n\n    local nginx_conf=\"\"\n    for conf_file in \"${nginx_conf_files[@]}\"; do\n        if [[ -f \"$conf_file\" ]]; then\n            nginx_conf=\"$conf_file\"\n            break\n        fi\n    done\n\n    if [[ -n \"$nginx_conf\" ]]; then\n        # Check if nginx has SSL configured\n        if grep -E '^\\s*listen\\s+443\\s+ssl' \"$nginx_conf\" &>/dev/null; then\n            print_success \"Production HTTPS: Nginx SSL listener configured (port 443)\"\n        else\n            print_warning \"Production HTTPS: Nginx may not have SSL configured\"\n            print_warning \"  Check nginx config has: listen 443 ssl;\"\n        fi\n\n        # Check for SSL certificate paths\n        if grep -E '^\\s*ssl_certificate\\s+' \"$nginx_conf\" &>/dev/null; then\n            local cert_path=$(grep -E '^\\s*ssl_certificate\\s+' \"$nginx_conf\" | head -1 | awk '{print $2}' | tr -d ';')\n            print_verbose \"SSL certificate path: $cert_path\"\n        else\n            print_warning \"Production HTTPS: No SSL certificate configured in nginx\"\n        fi\n    fi\n\n    return 0\n}\n\ncheck_websocket_cors_headers() {\n    print_verbose \"Checking CORS and security headers for WebSocket...\"\n\n    local nginx_conf_files=(\n        \"$PROJECT_ROOT/nginx/nginx.conf\"\n        \"$PROJECT_ROOT/nginx.conf\"\n        \"$PROJECT_ROOT/nginx/conf.d/default.conf\"\n    )\n\n    local nginx_conf=\"\"\n    for conf_file in \"${nginx_conf_files[@]}\"; do\n        if [[ -f \"$conf_file\" ]]; then\n            nginx_conf=\"$conf_file\"\n            break\n        fi\n    done\n\n    if [[ -z \"$nginx_conf\" ]]; then\n        print_verbose \"Nginx config not found, skipping CORS check\"\n        return 0\n    fi\n\n    # Check for problematic CORS headers that might block WebSocket\n    local has_cors_block=false\n    local has_upgrade_insecure=false\n    local ws_location_found=false\n\n    while IFS= read -r line; do\n        # Detect WebSocket location blocks\n        if echo \"$line\" | grep -E '^\\s*location\\s+.*(/ws|/api)' &>/dev/null; then\n            ws_location_found=true\n        fi\n\n        if [[ \"$ws_location_found\" == \"true\" ]]; then\n            # Check for CORS headers in WebSocket locations (can be problematic)\n            if echo \"$line\" | grep -E 'add_header.*Access-Control-Allow-Origin' &>/dev/null; then\n                has_cors_block=true\n                print_verbose \"Found CORS header in WebSocket location\"\n            fi\n\n            # Check for upgrade-insecure-requests (can block WSS)\n            if echo \"$line\" | grep -E 'upgrade-insecure-requests' &>/dev/null; then\n                has_upgrade_insecure=true\n            fi\n\n            if echo \"$line\" | grep -E '^\\s*}' &>/dev/null; then\n                ws_location_found=false\n            fi\n        fi\n    done < \"$nginx_conf\"\n\n    # Check if there are any security headers that might interfere\n    if grep -E '^\\s*add_header.*Content-Security-Policy.*upgrade-insecure-requests' \"$nginx_conf\" &>/dev/null; then\n        print_warning \"Security: Content-Security-Policy with upgrade-insecure-requests may affect WebSocket\"\n        print_warning \"  Fix: Ensure CSP allows WebSocket connections: connect-src 'self' ws: wss:;\"\n    fi\n\n    print_success \"CORS/Security: No blocking headers detected\"\n    return 0\n}\n\n# ==================== PLAN AND USER VALIDATION ====================\n\ncheck_plan_limits() {\n    print_verbose \"Checking self-hosted plan limits...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Get appflowy_cloud container ID\n    local container_id=$($compose_cmd ps -q appflowy_cloud 2>/dev/null)\n    if [[ -z \"$container_id\" ]]; then\n        print_verbose \"AppFlowy Cloud container not found\"\n        return 0\n    fi\n\n    # Parse logs for \"Free plan limits\" message\n    local logs=$(docker logs --tail 200 \"$container_id\" 2>&1)\n    local plan_limits=$(echo \"$logs\" | grep -i \"Free plan limits\" | tail -1)\n\n    if [[ -n \"$plan_limits\" ]]; then\n        # Extract max_users and max_guests from log line (BSD grep compatible)\n        local max_users=$(echo \"$plan_limits\" | sed -n 's/.*max_users:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p')\n        local max_guests=$(echo \"$plan_limits\" | sed -n 's/.*max_guests:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p')\n\n        # Fallback to \"unknown\" if extraction failed\n        [[ -z \"$max_users\" ]] && max_users=\"unknown\"\n        [[ -z \"$max_guests\" ]] && max_guests=\"unknown\"\n\n        if [[ \"$max_users\" != \"unknown\" && \"$max_guests\" != \"unknown\" ]]; then\n            print_info \"Self-Hosted Plan Configuration:\"\n            print_info \"  Max Users: $max_users\"\n            print_info \"  Max Guests: $max_guests\"\n\n            if [[ \"$max_users\" == \"1\" ]]; then\n                print_info \"  Note: Self-hosted version configured with 1-user limit\"\n                print_info \"        Admin and user can use different emails\"\n            fi\n\n            # Try to count existing users (if database accessible)\n            if $compose_cmd ps postgres &>/dev/null; then\n                local user_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_user WHERE deleted_at IS NULL;\" 2>/dev/null || echo \"unknown\")\n\n                if [[ \"$user_count\" != \"unknown\" && \"$user_count\" =~ ^[0-9]+$ ]]; then\n                    if [[ \"$max_users\" != \"unknown\" && \"$max_users\" =~ ^[0-9]+$ ]]; then\n                        if [[ $user_count -ge $max_users ]]; then\n                            print_warning \"User limit reached! ($user_count/$max_users users)\"\n                            print_warning \"  - New users will not be able to log in\"\n                            print_warning \"  - Consider removing unused accounts to free up slots\"\n                        else\n                            print_success \"Users: $user_count/$max_users (within limit)\"\n                        fi\n                    else\n                        print_info \"  Current user count: $user_count\"\n                    fi\n                fi\n            fi\n            echo \"\"\n        else\n            print_verbose \"Could not parse plan limits from logs\"\n        fi\n    else\n        print_verbose \"No plan limits found in logs (container may be using custom configuration)\"\n    fi\n\n    return 0\n}\n\ncheck_user_auth_flow() {\n    print_verbose \"Validating user authentication flow...\"\n\n    if ! load_env_vars; then\n        return 0\n    fi\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if GoTrue is configured for auto-confirm (important for testing)\n    local mailer_autoconfirm=\"${GOTRUE_MAILER_AUTOCONFIRM:-false}\"\n\n    if [[ \"$mailer_autoconfirm\" == \"false\" ]]; then\n        print_warning \"User Auth: Email confirmation required (GOTRUE_MAILER_AUTOCONFIRM=false)\"\n        print_warning \"  - New users must confirm email before login\"\n        print_warning \"  - Check SMTP settings if emails not arriving\"\n        print_warning \"  - For testing, set GOTRUE_MAILER_AUTOCONFIRM=true\"\n        echo \"\"\n    else\n        print_success \"User Auth: Auto-confirm enabled (good for testing)\"\n    fi\n\n    # Check for OTP configuration\n    local enable_otp=\"${GOTRUE_EXTERNAL_PHONE_ENABLED:-false}\"\n    if [[ \"$enable_otp\" == \"true\" ]]; then\n        print_info \"User Auth: OTP/Phone authentication enabled\"\n    fi\n\n    # Verify GoTrue is accessible\n    local gotrue_health=$(curl -s -o /dev/null -w \"%{http_code}\" \"http://localhost/gotrue/health\" --max-time 3 2>/dev/null)\n\n    if [[ \"$gotrue_health\" == \"200\" ]]; then\n        print_success \"User Auth: GoTrue service healthy\"\n    elif [[ \"$gotrue_health\" == \"404\" ]]; then\n        print_error \"User Auth: GoTrue health endpoint not found\"\n        print_error \"  This suggests nginx routing issues\"\n        print_error \"  Fix: Verify nginx.conf has /gotrue/* proxy configuration\"\n        echo \"\"\n    else\n        print_warning \"User Auth: GoTrue health check returned: $gotrue_health\"\n    fi\n\n    # Check for common auth flow issues in logs\n    local gotrue_container=$($compose_cmd ps -q gotrue 2>/dev/null)\n    if [[ -n \"$gotrue_container\" ]]; then\n        local recent_logs=$(docker logs --tail 100 \"$gotrue_container\" 2>&1)\n\n        # Look for email delivery issues\n        if echo \"$recent_logs\" | grep -qiE \"failed to send.*email|smtp.*error|mailer.*failed\"; then\n            print_error \"User Auth: Email delivery issues detected in GoTrue logs\"\n            print_error \"  Users may not receive confirmation emails\"\n            print_error \"  Check SMTP configuration in .env\"\n            echo \"\"\n        fi\n\n        # Look for token/JWT issues\n        if echo \"$recent_logs\" | grep -qiE \"invalid.*token|jwt.*error|signature.*invalid\"; then\n            print_error \"User Auth: JWT/Token validation issues detected\"\n            print_error \"  Verify GOTRUE_JWT_SECRET matches across services\"\n            echo \"\"\n        fi\n    fi\n\n    return 0\n}\n"
  },
  {
    "path": "script/lib/check_containers.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Container Checks\n# =============================================================================\n# This module handles Docker and container-related checks including:\n# - Docker installation verification\n# - Docker Compose detection\n# - Container status monitoring\n# - Service version detection\n# =============================================================================\n\n# ==================== DOCKER ENVIRONMENT ====================\n\ncheck_docker() {\n    print_verbose \"Checking Docker installation...\"\n\n    if ! command -v docker &> /dev/null; then\n        print_error \"Docker is not installed or not in PATH\"\n        return 1\n    fi\n\n    local docker_version=$(docker --version 2>/dev/null | awk '{print $3}' | sed 's/,//')\n    print_success \"Docker version: $docker_version\"\n\n    # Check if Docker daemon is running\n    if ! docker info &> /dev/null; then\n        print_error \"Docker daemon is not running\"\n        return 1\n    fi\n\n    print_verbose \"Docker daemon is running\"\n    return 0\n}\n\ncheck_docker_compose() {\n    print_verbose \"Checking Docker Compose installation...\"\n\n    # Try docker compose (v2)\n    if docker compose version &> /dev/null; then\n        local compose_version=$(docker compose version --short 2>/dev/null)\n        print_success \"Docker Compose: v$compose_version (v2)\"\n        echo \"docker compose\"\n        return 0\n    fi\n\n    # Try docker-compose (v1)\n    if command -v docker-compose &> /dev/null; then\n        local compose_version=$(docker-compose --version 2>/dev/null | awk '{print $3}' | sed 's/,//')\n        print_success \"Docker Compose: $compose_version (v1)\"\n        echo \"docker-compose\"\n        return 0\n    fi\n\n    print_error \"Docker Compose is not installed\"\n    return 1\n}\n\ndetect_compose_file() {\n    print_verbose \"Detecting docker-compose file...\"\n\n    if [[ -n \"$COMPOSE_FILE\" ]]; then\n        if [[ ! -f \"$PROJECT_ROOT/$COMPOSE_FILE\" ]]; then\n            print_error \"Specified compose file not found: $COMPOSE_FILE\"\n            return 1\n        fi\n        print_success \"Using specified compose file: $COMPOSE_FILE\"\n        return 0\n    fi\n\n    # Check for common compose files\n    if [[ -f \"$PROJECT_ROOT/docker-compose.yml\" ]]; then\n        COMPOSE_FILE=\"docker-compose.yml\"\n        print_success \"Found compose file: docker-compose.yml (production)\"\n        return 0\n    elif [[ -f \"$PROJECT_ROOT/docker-compose-dev.yml\" ]]; then\n        COMPOSE_FILE=\"docker-compose-dev.yml\"\n        print_success \"Found compose file: docker-compose-dev.yml (development)\"\n        return 0\n    else\n        print_error \"No docker-compose file found\"\n        return 1\n    fi\n}\n\ncheck_env_file() {\n    print_verbose \"Checking for .env file...\"\n\n    if [[ ! -f \"$PROJECT_ROOT/.env\" ]]; then\n        print_error \".env file not found. Run ./script/generate_env.sh first\"\n        return 1\n    fi\n\n    print_success \".env file found\"\n    return 0\n}\n\n# ==================== CONTAINER STATUS ====================\n\nget_compose_command() {\n    # Cache the compose command to avoid repeated calls\n    if [[ -z \"$COMPOSE_CMD\" ]]; then\n        # Call check_docker_compose without printing (it already printed in Phase 1)\n        local compose_cmd=\"\"\n        if docker compose version &> /dev/null; then\n            compose_cmd=\"docker compose\"\n        elif command -v docker-compose &> /dev/null; then\n            compose_cmd=\"docker-compose\"\n        else\n            print_error \"Docker Compose is not installed\"\n            return 1\n        fi\n        COMPOSE_CMD=\"$compose_cmd -f $PROJECT_ROOT/$COMPOSE_FILE\"\n    fi\n    echo \"$COMPOSE_CMD\"\n}\n\ncheck_container_status() {\n    print_verbose \"Checking container status...\"\n\n    local compose_cmd=$(get_compose_command)\n    local services=\"postgres redis gotrue appflowy_cloud admin_frontend nginx\"\n\n    # Check if any containers exist\n    local container_count=$($compose_cmd ps -a -q 2>/dev/null | wc -l)\n    if [[ $container_count -eq 0 ]]; then\n        print_warning \"No containers found. Run 'docker compose up -d' to start services\"\n        return 0\n    fi\n\n    for service in $services; do\n        # Check if service is defined in compose file\n        if ! $compose_cmd config --services 2>/dev/null | grep -q \"^${service}$\"; then\n            print_verbose \"Service '$service' not defined in compose file\"\n            continue\n        fi\n\n        # Get container status\n        local status=$($compose_cmd ps -q $service 2>/dev/null)\n        if [[ -z \"$status\" ]]; then\n            print_warning \"$service: Not created\"\n            continue\n        fi\n\n        local container_status=$(docker inspect --format='{{.State.Status}}' $status 2>/dev/null)\n        local restart_count=$(docker inspect --format='{{.RestartCount}}' $status 2>/dev/null)\n        local started_at=$(docker inspect --format='{{.State.StartedAt}}' $status 2>/dev/null)\n\n        case \"$container_status\" in\n            running)\n                if [[ \"$restart_count\" -gt 3 ]]; then\n                    print_warning \"$service: Running but has restarted $restart_count times\"\n                else\n                    print_success \"$service: Running (restarts: $restart_count)\"\n                fi\n                ;;\n            exited)\n                print_error \"$service: Exited\"\n                ;;\n            restarting)\n                print_error \"$service: Restarting (restart count: $restart_count)\"\n                ;;\n            *)\n                print_warning \"$service: Status unknown ($container_status)\"\n                ;;\n        esac\n    done\n}\n\ncheck_service_versions() {\n    print_verbose \"Checking service versions...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check AppFlowy Cloud version\n    local appflowy_cloud_container=$($compose_cmd ps -q appflowy_cloud 2>/dev/null)\n    if [[ -n \"$appflowy_cloud_container\" ]]; then\n        # Extract version from logs - look for \"Using AppFlowy Cloud version:X.X.X\"\n        APPFLOWY_CLOUD_VERSION=$(docker logs \"$appflowy_cloud_container\" 2>&1 | grep -oE 'Using AppFlowy Cloud version:[0-9]+\\.[0-9]+\\.[0-9]+' | head -1 | sed 's/Using AppFlowy Cloud version://')\n\n        if [[ -n \"$APPFLOWY_CLOUD_VERSION\" ]]; then\n            print_success \"AppFlowy Cloud version: $APPFLOWY_CLOUD_VERSION\"\n        else\n            print_verbose \"AppFlowy Cloud: Version not found in logs\"\n        fi\n\n        # Check deployment type\n        DEPLOYMENT_TYPE=$(docker logs \"$appflowy_cloud_container\" 2>&1 | grep -oE 'deployment:[a-z-]+' | head -1 | sed 's/deployment://')\n        if [[ -n \"$DEPLOYMENT_TYPE\" ]]; then\n            print_verbose \"Deployment type: $DEPLOYMENT_TYPE\"\n        fi\n    else\n        print_verbose \"AppFlowy Cloud: Container not running\"\n    fi\n\n    # Check Admin Frontend version\n    local admin_frontend_container=$($compose_cmd ps -q admin_frontend 2>/dev/null)\n    if [[ -n \"$admin_frontend_container\" ]]; then\n        # Extract version from logs - look for \"Version: X.X.X\"\n        ADMIN_FRONTEND_VERSION=$(docker logs \"$admin_frontend_container\" 2>&1 | grep -E '^Version:' | head -1 | awk '{print $2}')\n\n        if [[ -n \"$ADMIN_FRONTEND_VERSION\" ]]; then\n            print_success \"Admin Frontend version: $ADMIN_FRONTEND_VERSION\"\n        else\n            print_verbose \"Admin Frontend: Version not found in logs\"\n        fi\n    else\n        print_verbose \"Admin Frontend: Container not running\"\n    fi\n\n    # Check GoTrue version (from image tag)\n    local gotrue_container=$($compose_cmd ps -q gotrue 2>/dev/null)\n    if [[ -n \"$gotrue_container\" ]]; then\n        local gotrue_image=$(docker inspect --format='{{.Config.Image}}' \"$gotrue_container\" 2>/dev/null)\n        if [[ -n \"$gotrue_image\" ]]; then\n            print_verbose \"GoTrue image: $gotrue_image\"\n        fi\n    fi\n\n    return 0\n}\n"
  },
  {
    "path": "script/lib/check_functional.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Functional Tests\n# =============================================================================\n# This module handles functional testing of AppFlowy Cloud components including:\n# - Storage verification (Minio/S3)\n# - Database schema validation\n# - API endpoint testing\n# - WebSocket connectivity checks\n# - Admin frontend accessibility\n# - Collaboration data verification\n# - AI service integration\n# - Published features validation\n# - WebSocket connection simulation for login hang detection\n# =============================================================================\n\n# ==================== STORAGE CHECKS ====================\n\ncheck_minio_storage() {\n    print_verbose \"Checking Minio/S3 file storage...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if minio container is running\n    local minio_status=$($compose_cmd ps -q minio 2>/dev/null)\n    if [[ -z \"$minio_status\" ]]; then\n        print_warning \"Minio: Container not running (file storage unavailable)\"\n        return 1\n    fi\n\n    # Check if bucket exists\n    local bucket_check=$($compose_cmd exec -T minio sh -c 'ls -la /data/appflowy' 2>/dev/null)\n    if [[ $? -eq 0 ]]; then\n        local file_count=$(echo \"$bucket_check\" | wc -l)\n        print_success \"Minio: Bucket 'appflowy' exists with data\"\n        print_verbose \"Files in bucket: $file_count items\"\n    else\n        print_error \"Minio: Bucket 'appflowy' not found or inaccessible\"\n        return 1\n    fi\n\n    return 0\n}\n\n# ==================== DATABASE CHECKS ====================\n\ncheck_database_tables() {\n    print_verbose \"Checking database schema...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if postgres container is running\n    local pg_status=$($compose_cmd ps -q postgres 2>/dev/null)\n    if [[ -z \"$pg_status\" ]]; then\n        print_warning \"PostgreSQL: Container not running\"\n        return 1\n    fi\n\n    # Check for essential AppFlowy tables\n    local required_tables=(\n        \"af_workspace\"\n        \"af_collab\"\n        \"af_workspace_member\"\n        \"af_user\"\n    )\n\n    local missing_tables=()\n    for table in \"${required_tables[@]}\"; do\n        local table_exists=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '$table');\" 2>/dev/null)\n        if [[ \"$table_exists\" != \"t\" ]]; then\n            missing_tables+=(\"$table\")\n        fi\n    done\n\n    if [[ ${#missing_tables[@]} -gt 0 ]]; then\n        print_error \"Database: Missing tables: ${missing_tables[*]}\"\n        return 1\n    else\n        print_success \"Database: All essential tables exist\"\n    fi\n\n    return 0\n}\n\n# ==================== API ENDPOINT CHECKS ====================\n\ncheck_api_endpoints() {\n    print_verbose \"Checking API endpoints...\"\n\n    # Test public API endpoint (should return 401 for unauthorized)\n    local api_response=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost/api/workspace 2>/dev/null)\n\n    if [[ \"$api_response\" == \"401\" ]]; then\n        print_success \"API: Workspace endpoint responding (requires auth)\"\n    elif [[ \"$api_response\" == \"404\" ]]; then\n        print_error \"API: Workspace endpoint not found (routing issue)\"\n        return 1\n    elif [[ -z \"$api_response\" ]] || [[ \"$api_response\" == \"000\" ]]; then\n        print_error \"API: Cannot connect to API endpoints\"\n        return 1\n    else\n        print_success \"API: Workspace endpoint responding (HTTP $api_response)\"\n    fi\n\n    # Test GoTrue signup endpoint\n    local gotrue_response=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost/gotrue/signup 2>/dev/null)\n\n    if [[ \"$gotrue_response\" == \"400\" ]] || [[ \"$gotrue_response\" == \"422\" ]]; then\n        print_success \"GoTrue: Signup endpoint responding (requires data)\"\n    elif [[ \"$gotrue_response\" == \"404\" ]]; then\n        print_error \"GoTrue: Signup endpoint not found\"\n        return 1\n    else\n        print_verbose \"GoTrue: Signup endpoint HTTP $gotrue_response\"\n    fi\n\n    return 0\n}\n\n# ==================== WEBSOCKET CHECKS ====================\n\ncheck_websocket_endpoint() {\n    print_verbose \"Checking WebSocket endpoint...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Load WebSocket URL from .env to test the correct endpoint\n    if ! load_env_vars; then\n        print_warning \"WebSocket: Cannot load .env configuration\"\n        return 1\n    fi\n\n    # Extract path from APPFLOWY_WEBSOCKET_BASE_URL (e.g., ws://localhost/ws/v2 -> /ws/v2)\n    local ws_path=$(echo \"${APPFLOWY_WEBSOCKET_BASE_URL}\" | sed 's|^[^/]*//[^/]*/|/|')\n    if [[ -z \"$ws_path\" ]]; then\n        ws_path=\"/ws/v1\"  # Default fallback\n    fi\n\n    print_verbose \"Testing WebSocket path: $ws_path\"\n\n    # Check if this is v2 (requires workspace_id)\n    if [[ \"$ws_path\" == \"/ws/v2\" ]]; then\n        # Get a workspace ID from database\n        local pg_status=$($compose_cmd ps -q postgres 2>/dev/null)\n        if [[ -n \"$pg_status\" ]]; then\n            local workspace_id=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT workspace_id FROM af_workspace LIMIT 1;\" 2>/dev/null | tr -d '[:space:]')\n\n            if [[ -n \"$workspace_id\" ]] && [[ \"$workspace_id\" != \"\" ]]; then\n                ws_path=\"/ws/v2/${workspace_id}\"\n                print_verbose \"Using workspace ID: $workspace_id\"\n            else\n                print_info \"WebSocket v2: No workspaces yet (will be available after first workspace created)\"\n                return 0\n            fi\n        else\n            print_warning \"WebSocket v2: Cannot verify (database not accessible)\"\n            return 0\n        fi\n    fi\n\n    # Try to connect to WebSocket (will fail auth but proves endpoint exists)\n    local ws_response=$(curl -s --max-time 5 \\\n        -H \"Connection: Upgrade\" \\\n        -H \"Upgrade: websocket\" \\\n        -H \"Sec-WebSocket-Version: 13\" \\\n        -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" \\\n        http://localhost${ws_path} 2>&1)\n\n    # Check if response contains token/authorization error (this means endpoint works!)\n    if echo \"$ws_response\" | grep -qi \"token\\|authorization\\|Missing access token\"; then\n        print_success \"WebSocket: Endpoint responding at ws://localhost${ws_path} (auth required)\"\n        print_verbose \"Backend requires authentication (expected behavior)\"\n        return 0\n    fi\n\n    # Fallback: check HTTP status\n    local status_code=$(curl -s -o /dev/null -w \"%{http_code}\" \\\n        -H \"Connection: Upgrade\" \\\n        -H \"Upgrade: websocket\" \\\n        http://localhost${ws_path} 2>/dev/null)\n\n    if [[ \"$status_code\" == \"200\" ]]; then\n        print_success \"WebSocket: Endpoint responding at ws://localhost${ws_path}\"\n    elif [[ \"$status_code\" == \"400\" ]] || [[ \"$status_code\" == \"401\" ]] || [[ \"$status_code\" == \"403\" ]]; then\n        print_success \"WebSocket: Endpoint responding at ws://localhost${ws_path} (auth required)\"\n    elif [[ \"$status_code\" == \"404\" ]]; then\n        print_error \"WebSocket: Endpoint not found at ${ws_path}\"\n        if [[ \"$ws_path\" =~ \"/ws/v2\" ]]; then\n            print_error \"Note: WebSocket v2 requires format /ws/v2/{workspace_id}\"\n        fi\n        return 1\n    elif [[ \"$status_code\" == \"502\" ]] || [[ \"$status_code\" == \"503\" ]]; then\n        print_error \"WebSocket: Backend service unavailable (HTTP $status_code)\"\n        return 1\n    else\n        print_verbose \"WebSocket: HTTP $status_code at ${ws_path}\"\n        print_success \"WebSocket: Endpoint reachable (nginx routing configured)\"\n    fi\n\n    return 0\n}\n\ncheck_websocket_connection_simulation() {\n    print_verbose \"Simulating WebSocket connection for login hang detection...\"\n\n    if ! load_env_vars; then\n        print_warning \"WebSocket Simulation: Cannot load .env configuration\"\n        return 0\n    fi\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n    local ws_url=\"${APPFLOWY_WS_BASE_URL:-${APPFLOWY_WEBSOCKET_BASE_URL}}\"\n\n    if [[ -z \"$ws_url\" ]]; then\n        print_warning \"WebSocket Simulation: WS URL not configured, skipping connection test\"\n        return 0\n    fi\n\n    # Extract scheme and check for common misconfigurations\n    local ws_scheme=$(extract_url_scheme \"$ws_url\")\n    local base_scheme=$(extract_url_scheme \"$base_url\")\n\n    # Critical: Check for HTTPS + WS (not WSS) mismatch\n    if [[ \"$base_scheme\" == \"https\" && \"$ws_scheme\" == \"ws\" ]]; then\n        print_error \"WebSocket Connection: CRITICAL MISCONFIGURATION DETECTED\"\n        print_error \"  Base URL: $base_url (HTTPS)\"\n        print_error \"  WebSocket URL: $ws_url (WS)\"\n        print_error \"\"\n        print_error \"  ⚠️  This WILL cause login to hang at 100%!\"\n        print_error \"  Browsers block insecure WebSocket (ws://) from HTTPS pages\"\n        print_error \"\"\n        print_error \"  Fix: Change WS_SCHEME=wss in .env file\"\n        print_error \"  Then: docker compose up -d\"\n        echo \"\"\n        return 1\n    fi\n\n    # Test WebSocket endpoint availability (without auth)\n    local ws_test_url\n    if [[ \"$ws_url\" == *\"/ws/v2\" ]]; then\n        # For v2, test with a dummy workspace ID\n        ws_test_url=\"http://localhost/ws/v2/00000000-0000-0000-0000-000000000000\"\n    else\n        # For v1 or other versions\n        ws_test_url=\"http://localhost${ws_url##*${base_url}}\"\n    fi\n\n    # Try WebSocket upgrade request\n    local response=$(curl -s -o /dev/null -w \"%{http_code}\" \\\n        -X GET \"$ws_test_url\" \\\n        -H \"Upgrade: websocket\" \\\n        -H \"Connection: Upgrade\" \\\n        -H \"Sec-WebSocket-Version: 13\" \\\n        -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" \\\n        --max-time 5 2>/dev/null)\n\n    if [[ \"$response\" == \"401\" || \"$response\" == \"403\" ]]; then\n        print_success \"WebSocket Connection: Endpoint reachable (auth required - expected)\"\n        print_verbose \"  WebSocket upgrade request returned: $response\"\n    elif [[ \"$response\" == \"404\" ]]; then\n        print_error \"WebSocket Connection: Endpoint not found (404)\"\n        print_error \"  This will cause login to hang after authentication\"\n        print_error \"  Fix: Verify nginx WebSocket routing configuration\"\n        echo \"\"\n        return 1\n    elif [[ \"$response\" == \"502\" || \"$response\" == \"503\" ]]; then\n        print_error \"WebSocket Connection: Backend unavailable ($response)\"\n        print_error \"  AppFlowy Cloud service may not be running properly\"\n        print_error \"  Check: docker compose ps appflowy_cloud\"\n        echo \"\"\n        return 1\n    elif [[ \"$response\" == \"000\" ]]; then\n        print_warning \"WebSocket Connection: Connection timeout or refused\"\n        print_warning \"  This may indicate nginx or network issues\"\n    else\n        print_verbose \"WebSocket Connection: Received HTTP $response\"\n    fi\n\n    # Additional check: verify nginx WebSocket proxy configuration exists\n    local compose_cmd=$(get_compose_command)\n    local nginx_container=$($compose_cmd ps -q nginx 2>/dev/null)\n\n    if [[ -n \"$nginx_container\" ]]; then\n        local nginx_config=$(docker exec \"$nginx_container\" cat /etc/nginx/nginx.conf 2>/dev/null || echo \"\")\n\n        if [[ -n \"$nginx_config\" ]]; then\n            # Check for WebSocket upgrade headers\n            if ! echo \"$nginx_config\" | grep -q \"Upgrade.*\\$http_upgrade\"; then\n                print_warning \"WebSocket Connection: Nginx may be missing WebSocket upgrade headers\"\n                print_warning \"  This can cause connection failures after login\"\n            fi\n        fi\n    fi\n\n    return 0\n}\n\n# ==================== ADMIN FRONTEND CHECKS ====================\n\ncheck_admin_frontend() {\n    print_verbose \"Checking admin frontend...\"\n\n    # Try with trailing slash first (might redirect)\n    local admin_response=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost/console/ 2>/dev/null)\n\n    if [[ \"$admin_response\" == \"200\" ]]; then\n        print_success \"Admin Frontend: Accessible at http://localhost/console/\"\n    elif [[ \"$admin_response\" == \"308\" ]] || [[ \"$admin_response\" == \"301\" ]] || [[ \"$admin_response\" == \"302\" ]]; then\n        # Redirects are OK - try following redirect\n        local redirected_response=$(curl -s -L -o /dev/null -w \"%{http_code}\" http://localhost/console/ 2>/dev/null)\n        if [[ \"$redirected_response\" == \"200\" ]]; then\n            print_success \"Admin Frontend: Accessible at http://localhost/console/ (redirect $admin_response)\"\n        else\n            print_warning \"Admin Frontend: Redirect loop or error (HTTP $admin_response -> $redirected_response)\"\n        fi\n    elif [[ \"$admin_response\" == \"404\" ]]; then\n        print_error \"Admin Frontend: Not found (check ADMIN_FRONTEND_PATH_PREFIX in .env)\"\n        return 1\n    elif [[ \"$admin_response\" == \"502\" ]] || [[ \"$admin_response\" == \"503\" ]]; then\n        print_error \"Admin Frontend: Service unavailable (HTTP $admin_response)\"\n        return 1\n    else\n        print_warning \"Admin Frontend: Unexpected response (HTTP $admin_response)\"\n    fi\n\n    return 0\n}\n\n# ==================== COLLABORATION DATA CHECKS ====================\n\ncheck_collaboration_data() {\n    print_verbose \"Checking collaboration data...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if postgres container is running\n    local pg_status=$($compose_cmd ps -q postgres 2>/dev/null)\n    if [[ -z \"$pg_status\" ]]; then\n        print_warning \"PostgreSQL: Container not running\"\n        return 1\n    fi\n\n    # Check document count\n    local collab_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_collab;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$collab_count\" ]] && [[ \"$collab_count\" -gt 0 ]]; then\n        print_success \"Documents: $collab_count documents in database\"\n    else\n        print_info \"Documents: No documents yet (fresh install)\"\n    fi\n\n    # Check workspace count\n    local workspace_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_workspace;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$workspace_count\" ]] && [[ \"$workspace_count\" -gt 0 ]]; then\n        print_success \"Workspaces: $workspace_count workspace(s) created\"\n    else\n        print_info \"Workspaces: No workspaces yet (fresh install)\"\n    fi\n\n    # Check user count\n    local user_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_user;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$user_count\" ]] && [[ \"$user_count\" -gt 0 ]]; then\n        print_success \"Users: $user_count user(s) registered\"\n    else\n        print_info \"Users: No users yet (fresh install)\"\n    fi\n\n    # Check embeddings (for AI search features)\n    local embedding_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_collab_embeddings;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$embedding_count\" ]]; then\n        if [[ \"$embedding_count\" -gt 0 ]]; then\n            print_success \"AI Search: $embedding_count document embeddings indexed\"\n        else\n            print_verbose \"AI Search: No embeddings yet (indexing may be in progress)\"\n        fi\n    fi\n\n    return 0\n}\n\n# ==================== AI SERVICE CHECKS ====================\n\ncheck_ai_service() {\n    print_verbose \"Checking AI service integration...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if AI container is running\n    local ai_status=$($compose_cmd ps -q ai 2>/dev/null)\n    if [[ -z \"$ai_status\" ]]; then\n        print_info \"AI Service: Not running (optional feature)\"\n        return 0\n    fi\n\n    # Check AI service health\n    local ai_health=$(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5001/health 2>/dev/null)\n    if [[ \"$ai_health\" == \"200\" ]]; then\n        print_success \"AI Service: Healthy at http://localhost:5001\"\n    elif [[ \"$ai_health\" == \"404\" ]]; then\n        # Try internal health check\n        local ai_internal=$($compose_cmd exec -T ai sh -c 'curl -s http://localhost:5001/ 2>/dev/null' | head -20)\n        if [[ -n \"$ai_internal\" ]]; then\n            print_success \"AI Service: Running (internal check passed)\"\n        else\n            print_warning \"AI Service: Health endpoint unavailable\"\n        fi\n    else\n        print_verbose \"AI Service: HTTP $ai_health\"\n    fi\n\n    return 0\n}\n\n# ==================== PUBLISHED FEATURES CHECKS ====================\n\ncheck_published_features() {\n    print_verbose \"Checking publish/share features...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Check if postgres container is running\n    local pg_status=$($compose_cmd ps -q postgres 2>/dev/null)\n    if [[ -z \"$pg_status\" ]]; then\n        return 1\n    fi\n\n    # Check published documents\n    local published_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_published_collab;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$published_count\" ]]; then\n        if [[ \"$published_count\" -gt 0 ]]; then\n            print_success \"Published Docs: $published_count document(s) published\"\n        else\n            print_verbose \"Published Docs: No published documents yet\"\n        fi\n    fi\n\n    # Check workspace invitations\n    local invite_count=$($compose_cmd exec -T postgres psql -U postgres -d postgres -tAc \"SELECT COUNT(*) FROM af_workspace_invitation;\" 2>/dev/null)\n    if [[ $? -eq 0 ]] && [[ -n \"$invite_count\" ]] && [[ \"$invite_count\" -gt 0 ]]; then\n        print_verbose \"Invitations: $invite_count pending invitation(s)\"\n    fi\n\n    return 0\n}\n\n# ==================== FUNCTIONAL TEST SUITE ====================\n\nrun_functional_tests() {\n    [[ \"$QUICK_MODE\" == \"true\" ]] && return 0\n\n    print_verbose \"Running functional tests...\"\n\n    check_database_tables\n    check_minio_storage\n    check_api_endpoints\n    check_websocket_endpoint\n    check_websocket_connection_simulation\n    check_admin_frontend\n    check_collaboration_data\n    check_ai_service\n    check_published_features\n\n    return 0\n}\n"
  },
  {
    "path": "script/lib/check_health.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Health Checks\n# =============================================================================\n# This module handles service health endpoint checks including:\n# - HTTP health endpoint verification\n# - Service-specific health checks (GoTrue, AppFlowy Cloud)\n# - Database connectivity checks (PostgreSQL, Redis)\n# - Development vs Production mode detection\n# =============================================================================\n\n# ==================== HEALTH ENDPOINT CHECKS ====================\n\ncheck_health_endpoint() {\n    local service_name=\"$1\"\n    local url=\"$2\"\n    local timeout=\"${3:-5}\"\n\n    print_verbose \"Checking health endpoint: $url\"\n\n    local response=$(curl -s -f -m \"$timeout\" \"$url\" 2>/dev/null)\n    local exit_code=$?\n\n    if [[ $exit_code -eq 0 ]]; then\n        print_success \"$service_name health check: OK\"\n        print_verbose \"Response: $response\"\n        return 0\n    else\n        case $exit_code in\n            7)\n                print_error \"$service_name health check: Connection refused\"\n                ;;\n            28)\n                print_error \"$service_name health check: Timeout after ${timeout}s\"\n                ;;\n            *)\n                print_error \"$service_name health check: Failed (exit code: $exit_code)\"\n                ;;\n        esac\n        return 1\n    fi\n}\n\ncheck_health_endpoints() {\n    print_verbose \"Checking service health endpoints...\"\n\n    # Check if containers are running first\n    local compose_cmd=$(get_compose_command)\n    local container_count=$($compose_cmd ps -q 2>/dev/null | wc -l)\n    if [[ $container_count -eq 0 ]]; then\n        print_warning \"No containers running - skipping health checks\"\n        return 0\n    fi\n\n    # Determine if we're in dev or prod mode by checking ports\n    local gotrue_port=$($compose_cmd config 2>/dev/null | grep -A 10 \"gotrue:\" | grep \"9999:\" | head -1 | awk -F: '{print $1}' | grep -o \"[0-9]*$\")\n\n    if [[ -n \"$gotrue_port\" ]]; then\n        # Development mode - ports exposed\n        check_health_endpoint \"GoTrue\" \"http://localhost:9999/health\" 5\n        check_health_endpoint \"AppFlowy Cloud\" \"http://localhost:8000/health\" 5\n    else\n        # Production mode - via nginx\n        print_verbose \"Production mode: Services accessed via nginx reverse proxy\"\n        # Try through nginx if it's configured\n        if curl -s http://localhost/gotrue/health &>/dev/null; then\n            check_health_endpoint \"GoTrue (via nginx)\" \"http://localhost/gotrue/health\" 5\n            # Also try to check appflowy cloud through nginx\n            if curl -s http://localhost/api/health &>/dev/null; then\n                check_health_endpoint \"AppFlowy Cloud (via nginx)\" \"http://localhost/api/health\" 5\n            fi\n        else\n            print_warning \"Cannot access services via nginx - check if nginx is running\"\n        fi\n    fi\n\n    # Check database from container\n    local pg_status=$($compose_cmd ps -q postgres 2>/dev/null)\n    if [[ -n \"$pg_status\" ]]; then\n        local pg_check=$($compose_cmd exec -T postgres pg_isready -U postgres 2>/dev/null)\n        if [[ $? -eq 0 ]]; then\n            print_success \"PostgreSQL: $pg_check\"\n        else\n            print_error \"PostgreSQL: Not ready\"\n        fi\n    else\n        print_warning \"PostgreSQL: Container not running\"\n    fi\n\n    # Check Redis\n    local redis_status=$($compose_cmd ps -q redis 2>/dev/null)\n    if [[ -n \"$redis_status\" ]]; then\n        local redis_check=$($compose_cmd exec -T redis redis-cli ping 2>/dev/null)\n        if [[ \"$redis_check\" == \"PONG\" ]]; then\n            print_success \"Redis: PONG\"\n        else\n            print_error \"Redis: Not responding\"\n        fi\n    else\n        print_warning \"Redis: Container not running\"\n    fi\n}\n"
  },
  {
    "path": "script/lib/check_logs.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Log Analysis\n# =============================================================================\n# This module handles log analysis and error detection including:\n# - Admin Frontend connectivity and error checks\n# - GoTrue authentication error analysis\n# - Container error and crash detection\n# - Service log analysis for common issues\n# =============================================================================\n\n# ==================== ADMIN FRONTEND LOGS ====================\n\ncheck_admin_frontend_connectivity() {\n    print_verbose \"Checking Admin Frontend connectivity to AppFlowy Cloud...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Get admin_frontend container ID\n    local container_id=$($compose_cmd ps -q admin_frontend 2>/dev/null)\n    if [[ -z \"$container_id\" ]]; then\n        print_verbose \"Admin Frontend container not found\"\n        return 0\n    fi\n\n    if ! load_env_vars; then\n        return 1\n    fi\n\n    # Admin Frontend connects to AppFlowy Cloud via APPFLOWY_BASE_URL\n    # Analysis from docker logs and docker-compose.yml:\n    # - admin_frontend gets: APPFLOWY_BASE_URL and APPFLOWY_GOTRUE_BASE_URL\n    # - It makes API calls to APPFLOWY_BASE_URL (e.g., http://localhost)\n    # - nginx proxy routes these to appflowy_cloud backend\n\n    local base_url=\"${APPFLOWY_BASE_URL}\"\n\n    if [[ -z \"$base_url\" ]]; then\n        print_error \"Admin Frontend: APPFLOWY_BASE_URL not configured (CRITICAL)\"\n        print_error \"  Admin Frontend cannot connect to AppFlowy Cloud without this\"\n        return 1\n    fi\n\n    # Check admin_frontend logs for connection errors (last 5 minutes only)\n    local logs=$(docker logs --since 5m \"$container_id\" 2>&1)\n\n    # Find the last version number (indicates container restart)\n    # Only analyze logs after the last version number\n    local last_version_line=$(echo \"$logs\" | grep -n \"^Version:\" | tail -1 | cut -d: -f1)\n    if [[ -n \"$last_version_line\" ]]; then\n        logs=$(echo \"$logs\" | tail -n +$last_version_line)\n    fi\n\n    # Look for connection errors in admin_frontend logs\n    local connection_errors=$(echo \"$logs\" | grep -iE \"error|ERROR|failed|ECONNREFUSED|ETIMEDOUT|fetch failed|network error|cannot connect\" | grep -ivE \"debug|verbose\" | tail -15)\n\n    if [[ -n \"$connection_errors\" ]]; then\n        print_error \"Admin Frontend: Connection errors detected:\"\n        echo \"\"\n\n        local line_count=0\n        while IFS= read -r line; do\n            ((line_count++))\n            local clean_line=$(echo \"$line\" | cut -c1-250)\n            print_error \"  → $clean_line\"\n\n            if [[ $line_count -ge 10 ]]; then\n                print_error \"  ... (showing first 10 errors)\"\n                break\n            fi\n        done <<< \"$connection_errors\"\n\n        echo \"\"\n        print_error \"Common Admin Frontend login issues:\"\n        print_error \"  1. APPFLOWY_BASE_URL ($base_url) not accessible from admin_frontend container\"\n        print_error \"  2. Nginx not properly routing requests to appflowy_cloud backend\"\n        print_error \"  3. AppFlowy Cloud service not running or crashed\"\n        print_error \"  4. Network connectivity issues between containers\"\n        print_error \"\"\n        print_error \"Fix steps:\"\n        print_error \"  1. Verify APPFLOWY_BASE_URL in .env matches your deployment\"\n        print_error \"  2. Check nginx is running: docker compose ps nginx\"\n        print_error \"  3. Check appflowy_cloud is running: docker compose ps appflowy_cloud\"\n        print_error \"  4. Test from admin_frontend container:\"\n        print_error \"     docker compose exec admin_frontend wget -O- $base_url/api/health\"\n        echo \"\"\n\n        return 1\n    else\n        print_success \"Admin Frontend: No connection errors detected\"\n        return 0\n    fi\n}\n\ncheck_admin_frontend_errors() {\n    print_verbose \"Checking Admin Frontend specific errors...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Get admin_frontend container ID\n    local container_id=$($compose_cmd ps -q admin_frontend 2>/dev/null)\n    if [[ -z \"$container_id\" ]]; then\n        print_verbose \"Admin Frontend container not found\"\n        return 0\n    fi\n\n    # Check container status\n    local container_status=$(docker inspect --format='{{.State.Status}}' \"$container_id\" 2>/dev/null)\n    local restart_count=$(docker inspect --format='{{.RestartCount}}' \"$container_id\" 2>/dev/null)\n\n    # Get logs (last 5 minutes only)\n    local logs=$(docker logs --since 5m \"$container_id\" 2>&1)\n\n    # Find the last version number (indicates container restart)\n    # Only analyze logs after the last version number\n    local last_version_line=$(echo \"$logs\" | grep -n \"^Version:\" | tail -1 | cut -d: -f1)\n    if [[ -n \"$last_version_line\" ]]; then\n        logs=$(echo \"$logs\" | tail -n +$last_version_line)\n    fi\n\n    # Check for specific error patterns\n    local has_errors=false\n\n    # Pattern 1: EISDIR error (node_modules issue)\n    if echo \"$logs\" | grep -q \"EISDIR.*node_modules\"; then\n        has_errors=true\n        print_error \"Admin Frontend: EISDIR error detected (node_modules issue)\"\n        local error_line=$(echo \"$logs\" | grep \"EISDIR.*node_modules\" | tail -1 | cut -c1-200)\n        print_error \"  Error: $error_line\"\n        print_error \"  Cause: Likely a Docker volume or build cache issue\"\n        print_error \"  Fix: Try rebuilding the admin_frontend image:\"\n        print_error \"    docker compose build --no-cache admin_frontend\"\n        print_error \"    docker compose up -d admin_frontend\"\n        echo \"\"\n    fi\n\n    # Pattern 2: Module resolution errors\n    if echo \"$logs\" | grep -qiE \"Cannot find module|Module not found|Error: Cannot resolve\"; then\n        has_errors=true\n        print_error \"Admin Frontend: Module resolution error detected\"\n        local error_line=$(echo \"$logs\" | grep -iE \"Cannot find module|Module not found|Error: Cannot resolve\" | tail -1 | cut -c1-200)\n        print_error \"  Error: $error_line\"\n        print_error \"  Fix: Rebuild with fresh dependencies:\"\n        print_error \"    docker compose build --no-cache admin_frontend\"\n        echo \"\"\n    fi\n\n    # Pattern 3: Configuration injection failures\n    if echo \"$logs\" | grep -qiE \"Failed to inject configuration|Configuration error|APPFLOWY_BASE_URL.*undefined\"; then\n        has_errors=true\n        print_error \"Admin Frontend: Configuration injection failed\"\n        local error_line=$(echo \"$logs\" | grep -iE \"Failed to inject configuration|Configuration error\" | tail -1 | cut -c1-200)\n        print_error \"  Error: $error_line\"\n        print_error \"  Fix: Verify .env file has correct APPFLOWY_BASE_URL\"\n        print_error \"    Check: APPFLOWY_BASE_URL is set and matches your deployment URL\"\n        echo \"\"\n    fi\n\n    # Pattern 4: Port binding errors\n    if echo \"$logs\" | grep -qiE \"EADDRINUSE.*3000|port.*already in use\"; then\n        has_errors=true\n        print_error \"Admin Frontend: Port conflict detected\"\n        print_error \"  Error: Port 3000 already in use\"\n        print_error \"  Fix: Another service is using the port\"\n        print_error \"    Check: docker ps | grep 3000\"\n        echo \"\"\n    fi\n\n    # Pattern 5: Bun/Node runtime errors\n    if echo \"$logs\" | grep -qiE \"bun.*error|node.*fatal|v8::\"; then\n        has_errors=true\n        local error_line=$(echo \"$logs\" | grep -iE \"bun.*error|node.*fatal|v8::\" | tail -1 | cut -c1-200)\n        print_error \"Admin Frontend: Runtime error\"\n        print_error \"  Error: $error_line\"\n        echo \"\"\n    fi\n\n    # Check if container is constantly restarting\n    if [[ \"$restart_count\" -gt 3 ]]; then\n        print_error \"Admin Frontend: Container restarting frequently (count: $restart_count)\"\n        print_error \"  Status: $container_status\"\n\n        # Get the last startup attempt\n        local startup_logs=$(echo \"$logs\" | tail -50)\n        local last_error=$(echo \"$startup_logs\" | grep -iE \"error|fatal|failed\" | tail -3)\n\n        if [[ -n \"$last_error\" ]]; then\n            print_error \"  Recent errors:\"\n            while IFS= read -r line; do\n                local clean_line=$(echo \"$line\" | cut -c1-200)\n                print_error \"    $clean_line\"\n            done <<< \"$last_error\"\n        fi\n        echo \"\"\n        has_errors=true\n    fi\n\n    if [[ \"$has_errors\" == \"false\" && \"$container_status\" == \"running\" ]]; then\n        print_success \"Admin Frontend: No specific startup errors detected\"\n    fi\n\n    return 0\n}\n\n# ==================== GOTRUE AUTHENTICATION ====================\n\ncheck_gotrue_auth_errors() {\n    print_verbose \"Checking GoTrue authentication logs...\"\n\n    local compose_cmd=$(get_compose_command)\n\n    # Get GoTrue container ID\n    local container_id=$($compose_cmd ps -q gotrue 2>/dev/null)\n    if [[ -z \"$container_id\" ]]; then\n        print_verbose \"GoTrue container not found\"\n        return 0\n    fi\n\n    # Get GoTrue logs from last 5 minutes and look for authentication errors\n    # GoTrue logs are typically in text format with levels like ERROR, WARN, etc.\n    local logs=$(docker logs --since 5m \"$container_id\" 2>&1)\n\n    # Extract error-level logs related to authentication/login\n    local error_logs=$(echo \"$logs\" | grep -iE \"error|ERROR|fatal|FATAL|panic|failed|invalid\" | grep -ivE \"dbug|debug\" | tail -30)\n\n    if [[ -n \"$error_logs\" ]]; then\n        print_error \"GoTrue: Authentication errors detected in logs:\"\n        echo \"\"\n\n        local line_count=0\n        local found_auth_issues=false\n\n        while IFS= read -r line; do\n            ((line_count++))\n\n            # Check for common authentication error patterns\n            if echo \"$line\" | grep -qiE \"invalid.*credentials|authentication.*failed|invalid.*token|jwt.*invalid|password.*incorrect|user.*not.*found|login.*failed\"; then\n                found_auth_issues=true\n                local clean_line=$(echo \"$line\" | cut -c1-250)\n                print_error \"  → $clean_line\"\n            elif echo \"$line\" | grep -qiE \"error|ERROR|fatal|FATAL\"; then\n                # Show other errors too\n                local clean_line=$(echo \"$line\" | cut -c1-250)\n                print_error \"  → $clean_line\"\n            fi\n\n            # Limit output\n            if [[ $line_count -ge 20 ]]; then\n                print_error \"  ... (showing first 20 errors, check full logs for more)\"\n                break\n            fi\n        done <<< \"$error_logs\"\n\n        echo \"\"\n\n        if [[ \"$found_auth_issues\" == \"true\" ]]; then\n            print_error \"Common login issue causes:\"\n            print_error \"  1. Incorrect email/password\"\n            print_error \"  2. JWT secret mismatch between GoTrue and AppFlowy Cloud\"\n            print_error \"  3. User email not confirmed (check GOTRUE_MAILER_AUTOCONFIRM)\"\n            print_error \"  4. OAuth configuration errors (if using Google/GitHub login)\"\n        fi\n\n        print_error \"  Full GoTrue logs: docker logs $container_id --tail 200\"\n        print_error \"  Follow live: docker logs $container_id --follow\"\n        echo \"\"\n\n        return 1\n    else\n        print_success \"GoTrue: No authentication errors detected\"\n        return 0\n    fi\n}\n\n# ==================== CONTAINER ERROR ANALYSIS ====================\n\ncheck_container_errors() {\n    print_verbose \"Checking container crash logs and errors...\"\n\n    local compose_cmd=$(get_compose_command)\n    local services=\"postgres redis gotrue appflowy_cloud admin_frontend nginx appflowy_worker ai\"\n\n    for service in $services; do\n        # Check if service is defined in compose file\n        if ! $compose_cmd config --services 2>/dev/null | grep -q \"^${service}$\"; then\n            continue\n        fi\n\n        # Get container ID\n        local container_id=$($compose_cmd ps -q $service 2>/dev/null)\n        if [[ -z \"$container_id\" ]]; then\n            continue\n        fi\n\n        # Get restart count and status\n        local restart_count=$(docker inspect --format='{{.RestartCount}}' \"$container_id\" 2>/dev/null)\n        local container_status=$(docker inspect --format='{{.State.Status}}' \"$container_id\" 2>/dev/null)\n\n        # For GoTrue, always check logs even if running fine (login issues)\n        # For other services, only check if there are restart issues\n        local should_check_logs=false\n        if [[ \"$service\" == \"gotrue\" ]]; then\n            # Skip here, will be checked by check_gotrue_auth_errors\n            continue\n        elif [[ \"$restart_count\" -gt 3 ]] || [[ \"$container_status\" != \"running\" ]]; then\n            should_check_logs=true\n        fi\n\n        if [[ \"$should_check_logs\" == \"true\" ]]; then\n            print_warning \"$service: Analyzing logs (restarts: $restart_count, status: $container_status)\"\n\n            # Get logs from last 10 minutes to capture recent context\n            local logs=$(docker logs --since 10m \"$container_id\" 2>&1)\n\n            # Find the last version/startup marker (indicates container restart)\n            # Only analyze logs after the last restart\n            if [[ \"$service\" == \"appflowy_cloud\" ]]; then\n                local last_version_line=$(echo \"$logs\" | grep -n \"Using AppFlowy Cloud version:\" | tail -1 | cut -d: -f1)\n                if [[ -n \"$last_version_line\" ]]; then\n                    logs=$(echo \"$logs\" | tail -n +$last_version_line)\n                fi\n            elif [[ \"$service\" == \"admin_frontend\" ]]; then\n                local last_version_line=$(echo \"$logs\" | grep -n \"^Version:\" | tail -1 | cut -d: -f1)\n                if [[ -n \"$last_version_line\" ]]; then\n                    logs=$(echo \"$logs\" | tail -n +$last_version_line)\n                fi\n            fi\n\n            # Extract all error, warning, and fatal level logs\n            # Support both JSON format and plain text logs\n            local error_logs=$(echo \"$logs\" | grep -iE '\"level\":\"(error|fatal|panic|warn)\"|level=(error|fatal|panic|warn)|ERROR:|FATAL:|PANIC:|WARNING:|Failed to|Cannot|Error:|panic:' | tail -20)\n\n            if [[ -n \"$error_logs\" ]]; then\n                print_error \"$service: Error/Warning logs detected:\"\n                echo \"\"\n\n                local line_count=0\n                while IFS= read -r line; do\n                    ((line_count++))\n\n                    # Try to parse JSON logs for better formatting\n                    if echo \"$line\" | grep -q '\"timestamp\".*\"level\".*\"message\"'; then\n                        local timestamp=$(echo \"$line\" | sed -n 's/.*\"timestamp\":\"\\([^\"]*\\)\".*/\\1/p' | cut -c1-19)\n                        local level=$(echo \"$line\" | sed -n 's/.*\"level\":\"\\([^\"]*\\)\".*/\\1/p' | tr '[:lower:]' '[:upper:]')\n                        local message=$(echo \"$line\" | sed -n 's/.*\"message\":\"\\([^\"]*\\)\".*/\\1/p')\n\n                        if [[ -n \"$timestamp\" && -n \"$level\" && -n \"$message\" ]]; then\n                            print_error \"  [$timestamp] $level: $message\"\n                        else\n                            # Fallback to showing raw line\n                            local clean_line=$(echo \"$line\" | cut -c1-250)\n                            print_error \"  → $clean_line\"\n                        fi\n                    else\n                        # Plain text log - show with timestamp if available\n                        local clean_line=$(echo \"$line\" | cut -c1-250)\n                        print_error \"  → $clean_line\"\n                    fi\n\n                    # Limit output to prevent spam\n                    if [[ $line_count -ge 15 ]]; then\n                        print_error \"  ... (showing first 15 errors, check full logs for more)\"\n                        break\n                    fi\n                done <<< \"$error_logs\"\n\n                echo \"\"\n                print_error \"  Full logs: docker logs $container_id --tail 200\"\n                print_error \"  Follow live: docker logs $container_id --follow\"\n                echo \"\"\n            else\n                # No errors found, check for recent activity\n                local recent_logs=$(echo \"$logs\" | tail -5)\n                if [[ -n \"$recent_logs\" ]]; then\n                    print_info \"$service: No errors found, recent activity:\"\n                    while IFS= read -r line; do\n                        local clean_line=$(echo \"$line\" | cut -c1-200)\n                        print_verbose \"  $clean_line\"\n                    done <<< \"$recent_logs\"\n                    echo \"\"\n                fi\n            fi\n        fi\n    done\n\n    return 0\n}\n\nextract_container_crash_summary() {\n    print_verbose \"Generating container crash summary...\"\n\n    local compose_cmd=$(get_compose_command)\n    local services=\"postgres redis gotrue appflowy_cloud admin_frontend nginx appflowy_worker ai\"\n\n    local has_crashes=false\n\n    for service in $services; do\n        if ! $compose_cmd config --services 2>/dev/null | grep -q \"^${service}$\"; then\n            continue\n        fi\n\n        local container_id=$($compose_cmd ps -q $service 2>/dev/null)\n        if [[ -z \"$container_id\" ]]; then\n            continue\n        fi\n\n        local restart_count=$(docker inspect --format='{{.RestartCount}}' \"$container_id\" 2>/dev/null)\n        local container_status=$(docker inspect --format='{{.State.Status}}' \"$container_id\" 2>/dev/null)\n        local exit_code=$(docker inspect --format='{{.State.ExitCode}}' \"$container_id\" 2>/dev/null)\n\n        # Report containers with issues\n        if [[ \"$restart_count\" -gt 5 ]]; then\n            has_crashes=true\n            print_error \"$service: High restart count ($restart_count times)\"\n\n            # Get the most recent fatal error from last 10 minutes\n            local fatal_error=$(docker logs --since 10m \"$container_id\" 2>&1 | grep -iE \"fatal|panic|error\" | tail -1)\n            if [[ -n \"$fatal_error\" ]]; then\n                local clean_error=$(echo \"$fatal_error\" | cut -c1-150)\n                print_error \"  Last error: $clean_error\"\n            fi\n\n            # Provide fix suggestions\n            print_error \"  Fix: Check logs with: docker logs $container_id\"\n            echo \"\"\n        elif [[ \"$container_status\" == \"exited\" ]]; then\n            has_crashes=true\n            print_error \"$service: Container exited (exit code: $exit_code)\"\n\n            # Get exit reason from recent logs\n            local exit_msg=$(docker logs --since 10m \"$container_id\" 2>&1 | tail -10)\n            if [[ -n \"$exit_msg\" ]]; then\n                print_error \"  Recent logs:\"\n                while IFS= read -r line; do\n                    local clean_line=$(echo \"$line\" | cut -c1-150)\n                    print_error \"    $clean_line\"\n                done <<< \"$exit_msg\"\n            fi\n            echo \"\"\n        fi\n    done\n\n    if [[ \"$has_crashes\" == \"false\" ]]; then\n        print_success \"No container crashes detected - all services stable\"\n    fi\n\n    return 0\n}\n\n# ==================== SERVICE LOG ANALYSIS ====================\n\nanalyze_service_logs() {\n    [[ \"$SKIP_LOGS\" == \"true\" ]] && return 0\n\n    print_verbose \"Analyzing service logs...\"\n\n    local compose_cmd=$(get_compose_command)\n    local services=\"gotrue appflowy_cloud nginx admin_frontend\"\n\n    # Critical error patterns (actual problems)\n    local patterns=(\n        \"panic\"\n        \"fatal\"\n        \"FATAL\"\n        \"connection refused\"\n        \"cannot connect\"\n        \"failed to start\"\n        \"failed to connect\"\n        \"\\[error\\]\"\n    )\n\n    # Error level patterns (need context)\n    local error_patterns=(\n        '\"level\":\"error\"'\n        '\"level\":\"fatal\"'\n        'level=error'\n        'level=fatal'\n        'ERROR:'\n        'FATAL:'\n        '\\[warn\\]'\n    )\n\n    for service in $services; do\n        print_verbose \"Checking $service logs...\"\n\n        # Get logs from last 5 minutes only, exclude docker-compose warnings\n        local logs=$($compose_cmd logs --since=5m $service 2>&1 | grep -v '^time=\".*level=warning')\n\n        if [[ -z \"$logs\" ]]; then\n            print_verbose \"No logs available for $service\"\n            continue\n        fi\n\n        # Find the last version/startup marker (indicates container restart)\n        # Only analyze logs after the last restart\n        if [[ \"$service\" == \"appflowy_cloud\" ]]; then\n            local last_version_line=$(echo \"$logs\" | grep -n \"Using AppFlowy Cloud version:\" | tail -1 | cut -d: -f1)\n            if [[ -n \"$last_version_line\" ]]; then\n                logs=$(echo \"$logs\" | tail -n +$last_version_line)\n            fi\n        elif [[ \"$service\" == \"admin_frontend\" ]]; then\n            local last_version_line=$(echo \"$logs\" | grep -n \"^admin_frontend.*| Version:\" | tail -1 | cut -d: -f1)\n            if [[ -n \"$last_version_line\" ]]; then\n                logs=$(echo \"$logs\" | tail -n +$last_version_line)\n            fi\n        fi\n\n        # Search for critical error patterns\n        local found_errors=false\n        for pattern in \"${patterns[@]}\"; do\n            if echo \"$logs\" | grep -i \"$pattern\" &>/dev/null; then\n                if [[ \"$found_errors\" == \"false\" ]]; then\n                    print_error \"$service: Found critical issues in logs\"\n                    found_errors=true\n                fi\n\n                # Always show critical errors (not just when INCLUDE_LOGS=true)\n                local error_lines=$(echo \"$logs\" | grep -i \"$pattern\" | tail -3)\n                if [[ -n \"$error_lines\" ]]; then\n                    while IFS= read -r line; do\n                        local clean_line=$(echo \"$line\" | cut -c1-200)\n                        print_error \"  → $clean_line\"\n                    done <<< \"$error_lines\"\n                fi\n            fi\n        done\n\n        # Search for error level logs (less critical)\n        if [[ \"$found_errors\" == \"false\" ]]; then\n            for pattern in \"${error_patterns[@]}\"; do\n                if echo \"$logs\" | grep \"$pattern\" &>/dev/null; then\n                    print_warning \"$service: Found error-level log entries\"\n                    found_errors=true\n\n                    # Always show error entries (not just when INCLUDE_LOGS=true)\n                    local error_lines=$(echo \"$logs\" | grep \"$pattern\" | tail -3)\n                    if [[ -n \"$error_lines\" ]]; then\n                        while IFS= read -r line; do\n                            local clean_line=$(echo \"$line\" | cut -c1-200)\n                            print_warning \"  → $clean_line\"\n                        done <<< \"$error_lines\"\n                    fi\n                    break\n                fi\n            done\n        fi\n\n        if [[ \"$found_errors\" == \"false\" ]]; then\n            print_success \"$service: No errors in recent logs\"\n        fi\n    done\n}\n"
  },
  {
    "path": "script/lib/report.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Report Generation\n# =============================================================================\n# This module handles report generation and recommendation output including:\n# - Generating diagnostic reports with environment, version, and check results\n# - Analyzing issues and warnings to generate actionable recommendations\n# - Providing prioritized fix instructions for common problems\n# =============================================================================\n\n# ==================== REPORT GENERATION ====================\n\ngenerate_report() {\n    local report_file=\"${OUTPUT_FILE:-$REPORT_FILE}\"\n\n    print_verbose \"Generating report: $report_file\"\n\n    {\n        echo \"==========================================\"\n        echo \"AppFlowy Cloud Diagnostic Report\"\n        echo \"==========================================\"\n        echo \"Generated: $(date)\"\n        echo \"Version: $SCRIPT_VERSION\"\n        echo \"\"\n\n        echo \"=== ENVIRONMENT ===\"\n        echo \"OS: $(uname -s)\"\n        echo \"Docker: $(docker --version 2>/dev/null || echo 'Not installed')\"\n        echo \"Compose: $(docker compose version 2>/dev/null || docker-compose --version 2>/dev/null || echo 'Not installed')\"\n        echo \"Compose File: $COMPOSE_FILE\"\n        echo \"\"\n\n        echo \"=== SERVICE VERSIONS ===\"\n        if [[ -n \"$APPFLOWY_CLOUD_VERSION\" ]]; then\n            echo \"AppFlowy Cloud: $APPFLOWY_CLOUD_VERSION\"\n        else\n            echo \"AppFlowy Cloud: Unknown\"\n        fi\n        if [[ -n \"$ADMIN_FRONTEND_VERSION\" ]]; then\n            echo \"Admin Frontend: $ADMIN_FRONTEND_VERSION\"\n        else\n            echo \"Admin Frontend: Unknown\"\n        fi\n        if [[ -n \"$DEPLOYMENT_TYPE\" ]]; then\n            echo \"Deployment Type: $DEPLOYMENT_TYPE\"\n        fi\n        echo \"\"\n\n        if [[ ${#SUCCESSES[@]} -gt 0 ]]; then\n            echo \"=== SUCCESSES (${#SUCCESSES[@]}) ===\"\n            for success in \"${SUCCESSES[@]}\"; do\n                echo \"  ✓ $success\"\n            done\n            echo \"\"\n        fi\n\n        if [[ ${#WARNINGS[@]} -gt 0 ]]; then\n            echo \"=== WARNINGS (${#WARNINGS[@]}) ===\"\n            for warning in \"${WARNINGS[@]}\"; do\n                echo \"  ⚠ $warning\"\n            done\n            echo \"\"\n        fi\n\n        if [[ ${#ISSUES[@]} -gt 0 ]]; then\n            echo \"=== ISSUES (${#ISSUES[@]}) ===\"\n            for issue in \"${ISSUES[@]}\"; do\n                echo \"  ✗ $issue\"\n            done\n            echo \"\"\n        fi\n\n        echo \"=== RECOMMENDATIONS ===\"\n        generate_recommendations\n\n    } > \"$report_file\"\n\n    print_success \"Report saved to: $report_file\"\n}\n\ngenerate_recommendations() {\n    local has_critical=false\n\n    # Check for critical issues\n    for issue in \"${ISSUES[@]}\"; do\n        if echo \"$issue\" | grep -qi \"jwt\"; then\n            echo \"Priority 1 (CRITICAL): JWT Secret Mismatch\"\n            echo \"  - Issue: JWT secrets between GoTrue and AppFlowy Cloud don't match\"\n            echo \"  - Fix: Edit .env and ensure GOTRUE_JWT_SECRET equals APPFLOWY_GOTRUE_JWT_SECRET\"\n            echo \"  - Then restart: docker compose down && docker compose up -d\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"WebSocket.*wss://.*CRITICAL\\|WebSocket URL is ws://\"; then\n            echo \"Priority 1 (CRITICAL): WebSocket Scheme Mismatch\"\n            echo \"  - Issue: Using HTTPS but WebSocket URL is ws:// instead of wss://\"\n            echo \"  - Fix: Edit .env and change APPFLOWY_WS_BASE_URL to use wss://\"\n            echo \"  - Example: APPFLOWY_WS_BASE_URL=wss://yourdomain.com/ws\"\n            echo \"  - Then restart: docker compose restart appflowy_cloud nginx\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"Nginx.*Missing.*WebSocket\"; then\n            echo \"Priority 1 (CRITICAL): Nginx WebSocket Configuration Missing\"\n            echo \"  - Issue: Nginx is not configured to proxy WebSocket connections\"\n            echo \"  - Fix: Add WebSocket headers to nginx config:\"\n            echo \"    location /ws {\"\n            echo \"      proxy_pass http://appflowy_cloud:8000;\"\n            echo \"      proxy_http_version 1.1;\"\n            echo \"      proxy_set_header Upgrade \\$http_upgrade;\"\n            echo \"      proxy_set_header Connection \\\"upgrade\\\";\"\n            echo \"      proxy_set_header Host \\$host;\"\n            echo \"    }\"\n            echo \"  - Then: docker compose restart nginx\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"SSL.*certificate\"; then\n            echo \"Priority 1 (CRITICAL): SSL Certificate Issue\"\n            echo \"  - Issue: SSL/TLS certificate is invalid, expired, or missing\"\n            echo \"  - Fix: Install or renew SSL certificate\"\n            echo \"  - For Let's Encrypt: certbot certonly --standalone -d yourdomain.com\"\n            echo \"  - Update nginx config to point to certificate files\"\n            echo \"  - Then: docker compose restart nginx\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"not running\\|exited\\|restarting\"; then\n            echo \"Priority 1 (CRITICAL): Service Not Running\"\n            echo \"  - Issue: One or more critical services are not running\"\n            echo \"  - Fix: Check logs with: docker compose logs [service_name]\"\n            echo \"  - Restart: docker compose restart [service_name]\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"database\\|postgres\"; then\n            echo \"Priority 1 (CRITICAL): Database Connection Issue\"\n            echo \"  - Issue: Cannot connect to PostgreSQL\"\n            echo \"  - Fix: Check DATABASE_URL in .env matches PostgreSQL credentials\"\n            echo \"  - Verify: docker compose exec postgres pg_isready -U postgres\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"SMTP Configuration Incomplete\"; then\n            echo \"Priority 1 (CRITICAL): SMTP Configuration for Workspace Sharing\"\n            echo \"  - Issue: GOTRUE_SMTP configured but APPFLOWY_MAILER_SMTP is missing\"\n            echo \"  - Impact: Users CANNOT share workspaces or send invitations\"\n            echo \"  - Common mistake: Configuring only GOTRUE_SMTP (for auth) but forgetting APPFLOWY_MAILER_SMTP (for invitations)\"\n            echo \"  - Fix: Both SMTP configurations are required:\"\n            echo \"    - GOTRUE_SMTP_* for authentication emails (signup, password reset)\"\n            echo \"    - APPFLOWY_MAILER_SMTP_* for workspace invitations and sharing\"\n            echo \"  - Add to .env and restart: docker compose restart appflowy_cloud\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"High restart count\"; then\n            echo \"Priority 1 (CRITICAL): Container Crash Loop Detected\"\n            echo \"  - Issue: Container is repeatedly crashing and restarting\"\n            echo \"  - Common causes:\"\n            echo \"    - Configuration errors in .env file\"\n            echo \"    - Database connection failures\"\n            echo \"    - Missing or incorrect credentials\"\n            echo \"    - Port conflicts with other services\"\n            echo \"  - Fix:\"\n            echo \"    1. Check container logs: docker logs <container_id>\"\n            echo \"    2. Look for error messages at container startup\"\n            echo \"    3. Verify .env configuration matches requirements\"\n            echo \"    4. Ensure all dependent services (postgres, redis) are running\"\n            echo \"\"\n            has_critical=true\n        fi\n\n        if echo \"$issue\" | grep -qi \"Container exited\"; then\n            echo \"Priority 1 (CRITICAL): Container Failed to Start\"\n            echo \"  - Issue: Container exited and is not running\"\n            echo \"  - Fix:\"\n            echo \"    1. Check exit logs above for specific error\"\n            echo \"    2. Verify configuration in .env file\"\n            echo \"    3. Try: docker compose up <service_name> --force-recreate\"\n            echo \"    4. If persists, check: docker compose logs <service_name>\"\n            echo \"\"\n            has_critical=true\n        fi\n    done\n\n    # Check for warnings\n    for warning in \"${WARNINGS[@]}\"; do\n        if echo \"$warning\" | grep -qi \"restart\"; then\n            echo \"Priority 2 (Important): High Restart Count\"\n            echo \"  - Issue: Service is restarting frequently\"\n            echo \"  - Fix: Check service logs for crash reasons\"\n            echo \"  - Command: docker compose logs --tail=200 [service_name]\"\n            echo \"\"\n        fi\n    done\n\n    if [[ \"$has_critical\" == \"false\" && ${#ISSUES[@]} -eq 0 ]]; then\n        echo \"✓ No critical issues detected\"\n        echo \"\"\n        echo \"If you're still experiencing login or WebSocket problems, check:\"\n        echo \"  1. Browser console for JavaScript errors\"\n        echo \"  2. Network tab for failed API or WebSocket requests\"\n        echo \"  3. Ensure you're using the correct admin credentials\"\n        echo \"  4. Try clearing browser cache/cookies\"\n        echo \"  5. Check browser WebSocket connection in DevTools (Network > WS)\"\n    fi\n}\n"
  },
  {
    "path": "script/lib/utils.sh",
    "content": "#!/bin/bash\n\n# =============================================================================\n# AppFlowy Cloud - Diagnostic Tool - Utility Functions\n# =============================================================================\n# This module provides utility functions for output formatting, color support,\n# environment variable loading, and URL parsing.\n# =============================================================================\n\n# ==================== OUTPUT FUNCTIONS ====================\n\n# Function to print colored output\nprint_header() {\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"=================================\"\n        echo \"$1\"\n        echo \"=================================\"\n    else\n        echo -e \"${BLUE}=================================${NC}\"\n        echo -e \"${BLUE}$1${NC}\"\n        echo -e \"${BLUE}=================================${NC}\"\n    fi\n}\n\nprint_success() {\n    [[ \"$QUIET\" == \"true\" ]] && return\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"✓ $1\"\n    else\n        echo -e \"${GREEN}✓ $1${NC}\"\n    fi\n    SUCCESSES+=(\"$1\")\n}\n\nprint_warning() {\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"⚠ $1\"\n    else\n        echo -e \"${YELLOW}⚠ $1${NC}\"\n    fi\n    WARNINGS+=(\"$1\")\n}\n\nprint_error() {\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"✗ $1\"\n    else\n        echo -e \"${RED}✗ $1${NC}\"\n    fi\n    ISSUES+=(\"$1\")\n}\n\nprint_info() {\n    [[ \"$QUIET\" == \"true\" ]] && return\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"ℹ $1\"\n    else\n        echo -e \"${CYAN}ℹ $1${NC}\"\n    fi\n}\n\nprint_verbose() {\n    [[ \"$VERBOSE\" != \"true\" ]] && return\n    if [[ \"$NO_COLOR\" == \"true\" ]]; then\n        echo \"  $1\"\n    else\n        echo -e \"${PURPLE}  $1${NC}\"\n    fi\n}\n\n# ==================== DATA HANDLING ====================\n\n# Function to mask sensitive data\nmask_sensitive() {\n    local value=\"$1\"\n    local length=${#value}\n    if [[ $length -le 4 ]]; then\n        echo \"***\"\n    else\n        echo \"${value:0:2}***${value: -2}\"\n    fi\n}\n\n# ==================== ENVIRONMENT LOADING ====================\n\nload_env_vars() {\n    if [[ -f \"$PROJECT_ROOT/.env\" ]]; then\n        # Load env vars without exporting\n        set -a\n        source \"$PROJECT_ROOT/.env\"\n        set +a\n        return 0\n    fi\n    return 1\n}\n\n# ==================== URL PARSING ====================\n\n# Extract the scheme portion of a URL (http/https) for comparison.\nextract_url_scheme() {\n    local url=\"$1\"\n    printf '%s\\n' \"$url\" | awk -F:// 'NF > 1 {print tolower($1)}'\n}\n\nextract_url_host() {\n    local url=\"$1\"\n    printf '%s\\n' \"$url\" | sed -n 's#^[^:]*://\\([^/]*\\).*#\\1#p' | cut -d':' -f1\n}\n\nextract_url_path() {\n    local url=\"$1\"\n    printf '%s\\n' \"$url\" | sed -n 's#^[^:]*://[^/]*\\(.*\\)#\\1#p'\n}\n\n# ==================== HELP ====================\n\n# Function to show help\nshow_help() {\n    cat << EOF\nAppFlowy Cloud Diagnostic Tool v${SCRIPT_VERSION}\n\nUsage: $0 [OPTIONS]\n\nThis tool diagnoses common issues with AppFlowy Cloud docker-compose\ndeployments, particularly login and authentication problems.\n\nOptions:\n  -h, --help              Show this help message\n  -v, --verbose           Verbose output (show all checks)\n  -q, --quiet             Minimal output (errors only)\n  -f, --compose-file FILE Specify docker-compose file\n  -o, --output FILE       Save report to specific file\n  -l, --logs              Include full log analysis\n  -s, --skip-logs         Skip log analysis (faster)\n  --no-color              Disable colored output\n  --json                  Output in JSON format\n  --quick                 Quick mode (skip slow checks)\n\nExamples:\n  $0                      # Basic diagnostic\n  $0 -v -l                # Verbose with logs\n  $0 --quick              # Quick check\n  $0 -f docker-compose-dev.yml  # Custom compose file\n\nEOF\n}\n\n# ==================== ARGUMENT PARSING ====================\n\nparse_arguments() {\n    while [[ $# -gt 0 ]]; do\n        case $1 in\n            -h|--help)\n                show_help\n                exit 0\n                ;;\n            -v|--verbose)\n                VERBOSE=true\n                shift\n                ;;\n            -q|--quiet)\n                QUIET=true\n                shift\n                ;;\n            -f|--compose-file)\n                COMPOSE_FILE=\"$2\"\n                shift 2\n                ;;\n            -o|--output)\n                OUTPUT_FILE=\"$2\"\n                shift 2\n                ;;\n            -l|--logs)\n                INCLUDE_LOGS=true\n                shift\n                ;;\n            -s|--skip-logs)\n                SKIP_LOGS=true\n                shift\n                ;;\n            --no-color)\n                NO_COLOR=true\n                shift\n                ;;\n            --json)\n                JSON_OUTPUT=true\n                NO_COLOR=true\n                shift\n                ;;\n            --quick)\n                QUICK_MODE=true\n                SKIP_LOGS=true\n                shift\n                ;;\n            *)\n                echo \"Unknown option: $1\"\n                show_help\n                exit 1\n                ;;\n        esac\n    done\n}\n"
  },
  {
    "path": "script/redis/remove_redis_stream_range.sh",
    "content": "#!/bin/bash\n\n# delete all Redis stream entries matching the prefix that are older than the given date range\n# Usage: ./remove_redis_stream_range.sh stream_prefix 20231001 20231005 [--verbose]\n\n# Function to convert date string (YYYYMMDD) to Unix timestamp in milliseconds\ndate_to_timestamp_ms() {\n    date -j -f \"%Y%m%d\" \"$1\" \"+%s000\" 2>/dev/null || date -d \"$1\" \"+%s000\" 2>/dev/null\n}\n\n# Check if the correct number of arguments is provided\nif [ \"$#\" -lt 3 ] || [ \"$#\" -gt 4 ]; then\n    echo \"Usage: $0 <stream_prefix> <start_date> <end_date> [--verbose]\"\n    exit 1\nfi\n\n# Check if redis-cli is installed\nif ! command -v redis-cli &> /dev/null; then\n    echo \"redis-cli could not be found. Please install Redis CLI.\"\n    exit 1\nfi\n\n# Input stream prefix and date range in YYYYMMDD format\nstream_prefix=$1\nstart_date=$2\nend_date=$3\nverbose=false\n\n# Check for verbose flag\nif [ \"$#\" -eq 4 ] && [ \"$4\" == \"--verbose\" ]; then\n    verbose=true\nfi\n\n# Validate input date format\nif ! [[ $start_date =~ ^[0-9]{8}$ ]] || ! [[ $end_date =~ ^[0-9]{8}$ ]]; then\n    echo \"Invalid date format. Please use YYYYMMDD.\"\n    exit 1\nfi\n\n# Convert input dates to Unix timestamps in milliseconds\nstart_timestamp=$(date_to_timestamp_ms $start_date)\nend_timestamp=$(date_to_timestamp_ms $end_date)\n\nif [ $? -ne 0 ] || [ -z \"$start_timestamp\" ] || [ -z \"$end_timestamp\" ]; then\n    echo \"Error converting date to timestamp. Please check the input dates.\"\n    exit 1\nfi\n\n# Construct Redis stream IDs\nstart_id=\"${start_timestamp}-0\"\nend_id=\"${end_timestamp}-0\"\n\n# Initialize cursor for SCAN\ncursor=0\n\nwhile :; do\n    # Scan for keys with the specified prefix\n    result=$(redis-cli scan $cursor match \"${stream_prefix}*\")\n\n    # Extract cursor and keys\n    cursor=$(echo \"$result\" | head -n 1)\n    keys=$(echo \"$result\" | tail -n +2 | tr -d '\\r')\n\n    # Loop through the keys and delete entries within the range\n    for key in $keys; do\n        if $verbose; then\n            echo \"Query entries from stream: $key in the range: $start_id to $end_id\"\n        fi\n\n        # Fetch entries within the range\n        entries=$(redis-cli xrange $key $start_id $end_id)\n\n        # Loop through the entries and delete them\n        while IFS= read -r entry; do\n            entry_id=$(echo $entry | awk '{print $1}')\n            if [[ $entry_id =~ ^[0-9]+-[0-9]+$ ]]; then\n                redis-cli xdel $key $entry_id\n                if $verbose; then\n                    echo \"Deleted entry: $entry_id from stream: $key\"\n                fi\n            fi\n        done <<< \"$entries\"\n    done\n\n    # Break loop if cursor is 0\n    [ \"$cursor\" -eq 0 ] && break\ndone\n"
  },
  {
    "path": "script/redis/show_redis_stream_values.sh",
    "content": "#!/bin/bash\n\n# Show all Redis stream values matching the given prefix\n# Usage: ./show_redis_stream_values.sh stream_prefix\n\n# Check if the correct number of arguments is provided\nif [ \"$#\" -ne 1 ]; then\n    echo \"Usage: $0 <stream_prefix>\"\n    exit 1\nfi\n\n# Check if redis-cli is installed\nif ! command -v redis-cli &> /dev/null\nthen\n    echo \"redis-cli could not be found. Please install Redis CLI.\"\n    exit 1\nfi\n\n# Input stream prefix\nstream_prefix=$1\n\n# Initialize cursor for SCAN\ncursor=0\n\nwhile :; do\n    # Scan for keys with the specified prefix\n    result=$(redis-cli scan $cursor match \"${stream_prefix}*\")\n\n    # Extract cursor and keys\n    cursor=$(echo \"$result\" | head -n 1)\n    keys=$(echo \"$result\" | tail -n +2)\n    echo \"Found keys: $(echo $keys | wc -w)\"\n\n    # Loop through the keys and show entries\n    for key in $keys; do\n        echo \"Entries for stream key: $key\"\n        redis-cli xrange $key - + | while read -r entry_id fields; do\n            echo \"Entry ID: $entry_id\"\n            echo \"Fields: $fields\"\n        done\n    done\n\n    # Break loop if cursor is 0\n    [ \"$cursor\" -eq 0 ] && break\ndone\n"
  },
  {
    "path": "script/reset-password-interactive.sh",
    "content": "#!/bin/bash\n# Interactive Password Reset Script for GoTrue\n# Usage: ./reset-password-interactive.sh\n\nset -e  # Exit on error\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\n\necho -e \"${CYAN}=== GoTrue Interactive Password Reset ===${NC}\"\necho \"\"\n\n# Check for required tools\necho \"Checking dependencies...\"\n\n# Check Python3\nif ! command -v python3 &> /dev/null; then\n    echo -e \"${RED}ERROR: python3 is not installed${NC}\"\n    echo \"Please install Python 3 first:\"\n    echo \"  - macOS: brew install python3\"\n    echo \"  - Ubuntu/Debian: sudo apt-get install python3\"\n    echo \"  - CentOS/RHEL: sudo yum install python3\"\n    exit 1\nfi\n\n# Check pip3\nif ! command -v pip3 &> /dev/null; then\n    echo -e \"${RED}ERROR: pip3 is not installed${NC}\"\n    exit 1\nfi\n\n# Check Docker\nif ! command -v docker &> /dev/null; then\n    echo -e \"${RED}ERROR: docker is not installed${NC}\"\n    echo \"Please install Docker first: https://docs.docker.com/get-docker/\"\n    exit 1\nfi\n\n# Check if bcrypt is available\nif ! python3 -c \"import bcrypt\" 2>/dev/null; then\n    echo \"\"\n    echo -e \"${YELLOW}WARNING: Python 'bcrypt' library is not installed${NC}\"\n    echo \"This script requires the 'bcrypt' library to generate password hashes.\"\n    echo \"\"\n    read -p \"Would you like to install it now? (y/n) \" -n 1 -r\n    echo \"\"\n\n    if [[ $REPLY =~ ^[Yy]$ ]]; then\n        echo \"Installing bcrypt library...\"\n        if pip3 install bcrypt --quiet --user; then\n            echo -e \"${GREEN}✓${NC} bcrypt installed successfully\"\n        else\n            echo -e \"${RED}ERROR: Failed to install bcrypt${NC}\"\n            exit 1\n        fi\n    else\n        echo -e \"${RED}ERROR: Cannot proceed without bcrypt library${NC}\"\n        exit 1\n    fi\nfi\n\necho \"\"\necho -e \"${CYAN}Finding PostgreSQL containers...${NC}\"\necho \"\"\n\n# Find all containers with postgres in the image name or container name\nPOSTGRES_CONTAINERS=$(docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}' | grep -i postgres)\n\nif [ -z \"$POSTGRES_CONTAINERS\" ]; then\n    echo -e \"${RED}ERROR: No PostgreSQL containers found${NC}\"\n    echo \"\"\n    echo \"Available containers:\"\n    docker ps -a --format \"table {{.Names}}\\t{{.Image}}\\t{{.Status}}\"\n    exit 1\nfi\n\n# Count containers\nCONTAINER_COUNT=$(echo \"$POSTGRES_CONTAINERS\" | wc -l | tr -d ' ')\n\nif [ \"$CONTAINER_COUNT\" -eq 1 ]; then\n    # Only one container, use it automatically\n    SELECTED_CONTAINER=$(echo \"$POSTGRES_CONTAINERS\" | cut -d'|' -f1)\n    CONTAINER_IMAGE=$(echo \"$POSTGRES_CONTAINERS\" | cut -d'|' -f2)\n    CONTAINER_STATUS=$(echo \"$POSTGRES_CONTAINERS\" | cut -d'|' -f3)\n\n    echo -e \"${GREEN}Found 1 PostgreSQL container:${NC}\"\n    echo \"  Name: $SELECTED_CONTAINER\"\n    echo \"  Image: $CONTAINER_IMAGE\"\n    echo \"  Status: $CONTAINER_STATUS\"\n    echo \"\"\n\n    # Check if running\n    if ! echo \"$CONTAINER_STATUS\" | grep -q \"Up\"; then\n        echo -e \"${YELLOW}WARNING: Container is not running${NC}\"\n        read -p \"Do you want to continue anyway? (y/n) \" -n 1 -r\n        echo \"\"\n        if [[ ! $REPLY =~ ^[Yy]$ ]]; then\n            exit 1\n        fi\n    fi\nelse\n    # Multiple containers - let user choose\n    echo -e \"${BLUE}Found $CONTAINER_COUNT PostgreSQL containers:${NC}\"\n    echo \"\"\n    printf \"${CYAN}%-4s %-40s %-30s %-20s${NC}\\n\" \"No.\" \"Container Name\" \"Image\" \"Status\"\n    echo \"--------------------------------------------------------------------------------------------------------\"\n\n    declare -a container_names\n    index=1\n    while IFS='|' read -r name image status; do\n        container_names[$index]=\"$name\"\n\n        # Truncate long names/images for display\n        display_name=\"${name:0:40}\"\n        display_image=\"${image:0:30}\"\n        display_status=\"${status:0:20}\"\n\n        printf \"%-4s %-40s %-30s %-20s\\n\" \"$index\" \"$display_name\" \"$display_image\" \"$display_status\"\n        ((index++))\n    done <<< \"$POSTGRES_CONTAINERS\"\n\n    echo \"\"\n\n    # Prompt user to select\n    while true; do\n        read -p \"Enter the number of the PostgreSQL container to use (or 'q' to quit): \" selection\n\n        if [ \"$selection\" = \"q\" ] || [ \"$selection\" = \"Q\" ]; then\n            echo \"Cancelled.\"\n            exit 0\n        fi\n\n        # Check if selection is a valid number\n        if ! [[ \"$selection\" =~ ^[0-9]+$ ]]; then\n            echo -e \"${RED}Invalid input. Please enter a number.${NC}\"\n            continue\n        fi\n\n        if [ \"$selection\" -lt 1 ] || [ \"$selection\" -gt \"$CONTAINER_COUNT\" ]; then\n            echo -e \"${RED}Invalid selection. Please choose between 1 and $CONTAINER_COUNT.${NC}\"\n            continue\n        fi\n\n        break\n    done\n\n    SELECTED_CONTAINER=\"${container_names[$selection]}\"\n\n    # Get container details\n    CONTAINER_INFO=$(docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}' --filter \"name=^${SELECTED_CONTAINER}$\")\n    CONTAINER_IMAGE=$(echo \"$CONTAINER_INFO\" | cut -d'|' -f2)\n    CONTAINER_STATUS=$(echo \"$CONTAINER_INFO\" | cut -d'|' -f3)\n\n    echo \"\"\n    echo -e \"${GREEN}Selected container:${NC}\"\n    echo \"  Name: $SELECTED_CONTAINER\"\n    echo \"  Image: $CONTAINER_IMAGE\"\n    echo \"  Status: $CONTAINER_STATUS\"\n    echo \"\"\n\n    # Check if running\n    if ! echo \"$CONTAINER_STATUS\" | grep -q \"Up\"; then\n        echo -e \"${YELLOW}WARNING: Container is not running${NC}\"\n        read -p \"Do you want to continue anyway? (y/n) \" -n 1 -r\n        echo \"\"\n        if [[ ! $REPLY =~ ^[Yy]$ ]]; then\n            exit 1\n        fi\n    fi\nfi\n\n# Get database credentials\nPGUSER=$(docker exec \"$SELECTED_CONTAINER\" printenv POSTGRES_USER 2>/dev/null || echo \"postgres\")\nPGDATABASE=$(docker exec \"$SELECTED_CONTAINER\" printenv POSTGRES_DB 2>/dev/null || echo \"postgres\")\n\necho -e \"${GREEN}✓${NC} Using PostgreSQL container: $SELECTED_CONTAINER\"\necho -e \"${GREEN}✓${NC} Database: $PGDATABASE (User: $PGUSER)\"\necho \"\"\n\n# Fetch users from database\necho -e \"${CYAN}Fetching users from database...${NC}\"\necho \"\"\n\n# Get users data into a temporary file\nTEMP_FILE=$(mktemp)\ndocker exec \"$SELECTED_CONTAINER\" psql -U \"$PGUSER\" -d \"$PGDATABASE\" -t -A -F'|' -c \"\nSELECT\n    email,\n    to_char(created_at, 'YYYY-MM-DD HH24:MI:SS'),\n    to_char(last_sign_in_at, 'YYYY-MM-DD HH24:MI:SS'),\n    CASE WHEN confirmed_at IS NOT NULL THEN 'Yes' ELSE 'No' END,\n    CASE\n        WHEN is_super_admin = true THEN 'Yes'\n        WHEN role LIKE '%admin%' THEN 'Yes'\n        ELSE 'No'\n    END\nFROM auth.users\nORDER BY created_at DESC;\n\" > \"$TEMP_FILE\"\n\n# Check if there are any users\nif [ ! -s \"$TEMP_FILE\" ]; then\n    echo -e \"${RED}No users found in the database${NC}\"\n    rm \"$TEMP_FILE\"\n    exit 1\nfi\n\n# Display users in a table format\necho -e \"${BLUE}Available Users:${NC}\"\necho \"\"\nprintf \"${CYAN}%-4s %-30s %-20s %-20s %-10s %-8s${NC}\\n\" \"No.\" \"Email\" \"Created\" \"Last Sign-in\" \"Confirmed\" \"Admin\"\necho \"------------------------------------------------------------------------------------------------------------\"\n\n# Read users into an array and display them\ndeclare -a emails\nindex=1\nwhile IFS='|' read -r email created last_signin confirmed admin; do\n    # Handle null/empty last_signin\n    if [ -z \"$last_signin\" ] || [ \"$last_signin\" = \" \" ]; then\n        last_signin=\"Never\"\n    fi\n\n    emails[$index]=\"$email\"\n    printf \"%-4s %-30s %-20s %-20s %-10s %-8s\\n\" \"$index\" \"$email\" \"$created\" \"$last_signin\" \"$confirmed\" \"$admin\"\n    ((index++))\ndone < \"$TEMP_FILE\"\n\ntotal_users=$((index - 1))\necho \"\"\necho -e \"${GREEN}Total users: $total_users${NC}\"\necho \"\"\n\n# Prompt user to select\nwhile true; do\n    read -p \"Enter the number of the user to reset password (or 'q' to quit): \" selection\n\n    if [ \"$selection\" = \"q\" ] || [ \"$selection\" = \"Q\" ]; then\n        echo \"Cancelled.\"\n        rm \"$TEMP_FILE\"\n        exit 0\n    fi\n\n    # Check if selection is a valid number\n    if ! [[ \"$selection\" =~ ^[0-9]+$ ]]; then\n        echo -e \"${RED}Invalid input. Please enter a number.${NC}\"\n        continue\n    fi\n\n    if [ \"$selection\" -lt 1 ] || [ \"$selection\" -gt \"$total_users\" ]; then\n        echo -e \"${RED}Invalid selection. Please choose between 1 and $total_users.${NC}\"\n        continue\n    fi\n\n    break\ndone\n\nSELECTED_EMAIL=\"${emails[$selection]}\"\necho \"\"\necho -e \"${GREEN}Selected user:${NC} $SELECTED_EMAIL\"\necho \"\"\n\n# Prompt for new password\nwhile true; do\n    read -p \"Enter new password: \" password1\n    echo \"\"\n\n    if [ ${#password1} -lt 6 ]; then\n        echo -e \"${RED}Password must be at least 6 characters long${NC}\"\n        continue\n    fi\n\n    read -p \"Confirm new password: \" password2\n    echo \"\"\n\n    if [ \"$password1\" != \"$password2\" ]; then\n        echo -e \"${RED}Passwords do not match. Please try again.${NC}\"\n        echo \"\"\n        continue\n    fi\n\n    break\ndone\n\nNEW_PASSWORD=\"$password1\"\necho \"\"\n\n# Confirm action\necho -e \"${YELLOW}You are about to reset the password for:${NC}\"\necho -e \"  Email: ${GREEN}$SELECTED_EMAIL${NC}\"\necho \"\"\nread -p \"Are you sure you want to proceed? (yes/no) \" -r\necho \"\"\n\nif [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then\n    echo \"Cancelled.\"\n    rm \"$TEMP_FILE\"\n    exit 0\nfi\n\necho -e \"${CYAN}Resetting password...${NC}\"\necho \"\"\n\n# Generate bcrypt hash\necho \"Step 1: Generating bcrypt hash...\"\nBCRYPT_HASH=$(python3 << EOF\nimport bcrypt\npassword = \"$NEW_PASSWORD\"\nsalt = bcrypt.gensalt(rounds=10)\nhashed = bcrypt.hashpw(password.encode('utf-8'), salt)\nprint(hashed.decode('utf-8'))\nEOF\n)\n\nif [ -z \"$BCRYPT_HASH\" ]; then\n    echo -e \"${RED}ERROR: Failed to generate bcrypt hash${NC}\"\n    rm \"$TEMP_FILE\"\n    exit 1\nfi\n\necho -e \"${GREEN}✓${NC} Hash generated successfully\"\necho \"\"\n\n# Update password in database\necho \"Step 2: Updating password in database...\"\nRESULT=$(docker exec \"$SELECTED_CONTAINER\" psql -U \"$PGUSER\" -d \"$PGDATABASE\" -t -c \"\nUPDATE auth.users\nSET encrypted_password = '$BCRYPT_HASH',\n    updated_at = now()\nWHERE email = '$SELECTED_EMAIL'\nRETURNING email;\n\" 2>&1)\n\nif [ $? -ne 0 ]; then\n    echo -e \"${RED}ERROR: Failed to update password in database${NC}\"\n    echo \"Details: $RESULT\"\n    rm \"$TEMP_FILE\"\n    exit 1\nfi\n\necho -e \"${GREEN}✓${NC} Password updated successfully!\"\necho \"\"\n\n# Clean up\nrm \"$TEMP_FILE\"\n\necho -e \"${GREEN}=== Password Reset Complete ===${NC}\"\necho \"\"\necho -e \"Email: ${GREEN}$SELECTED_EMAIL${NC}\"\necho -e \"Status: ${GREEN}Password has been reset${NC}\"\necho \"\"\necho -e \"${YELLOW}Important:${NC}\"\necho \"1. The user can now log in with the new password\"\necho \"2. Consider notifying the user about the password change\"\necho \"3. Recommend the user to change their password after logging in\"\necho \"\"\n"
  },
  {
    "path": "script/run_ci_server.sh",
    "content": "#!/usr/bin/env bash\n\n#===============================================================================\n# AppFlowy Cloud CI Server Runner\n#===============================================================================\n#\n# DESCRIPTION:\n#   This script builds and runs AppFlowy Cloud services using Docker Compose.\n#   It supports building individual services or combinations of services,\n#   with options for development or production builds.\n#\n# PREREQUISITES:\n#   - Docker and Docker Compose installed\n#   - .env file exists (copy from deploy.env and customize)\n#\n# USAGE:\n#   ./run_ci_server.sh [SERVICE] [VERSION] [OPTIONS]\n#\n# SERVICES:\n#   cloud           - Build only the cloud service\n#   worker          - Build only the worker service  \n#   admin_frontend  - Build only the admin frontend service\n#   all             - Build cloud + worker services (default)\n#   full            - Build all three services\n#\n# VERSION:\n#   [version-tag]   - Docker image tag (default: \"latest\")\n#\n# ENVIRONMENT VARIABLES:\n#   SKIP_BUILD=1    - Skip building, pull existing images instead\n#   RELEASE_BUILD=1 - Build with release profile (optimized, slower)\n#\n# EXAMPLES:\n#   # Build all services with latest tag (development mode)\n#   ./run_ci_server.sh\n#   ./run_ci_server.sh all\n#\n#   # Build specific service with custom version\n#   ./run_ci_server.sh cloud v1.2.3\n#   ./run_ci_server.sh worker v2.0.0\n#   ./run_ci_server.sh admin_frontend latest\n#\n#   # Build all services including admin frontend\n#   ./run_ci_server.sh full v1.2.3\n#\n#   # Skip build and use existing images\n#   SKIP_BUILD=1 ./run_ci_server.sh cloud v1.2.3\n#\n#   # Build with production optimizations (slower but optimized)\n#   RELEASE_BUILD=1 ./run_ci_server.sh full v1.2.3\n#\n#   # Combine options\n#   SKIP_BUILD=1 ./run_ci_server.sh all latest\n#\n# NOTES:\n#   - Script automatically updates .env with localhost URLs for testing\n#   - Original .env is backed up as .env.backup\n#   - Use 'cp .env.backup .env' to restore original settings\n#   - Services are accessible via nginx proxy at http://localhost\n#\n#===============================================================================\n\nset -eo pipefail\nset -x\n\ncd \"$(dirname \"$0\")/..\"\n\n# Check .env\nif [[ ! -f \".env\" ]]; then\n  echo \".env file does not exist. Please copy deploy.env to .env and update the values.\"\n  exit 1\nfi\n\n# Parse args\nSERVICE=${1:-all}           # cloud, worker, admin_frontend, all, or full\nIMAGE_VERSION=${2:-latest}  # tag, defaults to \"latest\"\n\ncase \"$SERVICE\" in\n  cloud|appflowy_cloud)\n    BUILD_CLOUD=true\n    BUILD_WORKER=false\n    BUILD_ADMIN_FRONTEND=false\n    ;;\n  worker|appflowy_worker)\n    BUILD_CLOUD=false\n    BUILD_WORKER=true\n    BUILD_ADMIN_FRONTEND=false\n    ;;\n  admin_frontend)\n    BUILD_CLOUD=false\n    BUILD_WORKER=false\n    BUILD_ADMIN_FRONTEND=true\n    ;;\n  all)\n    BUILD_CLOUD=true\n    BUILD_WORKER=true\n    BUILD_ADMIN_FRONTEND=false\n    ;;\n  full)\n    BUILD_CLOUD=true\n    BUILD_WORKER=true\n    BUILD_ADMIN_FRONTEND=true\n    ;;\n  *)\n    echo \"Usage: $0 [cloud|worker|admin_frontend|all|full] [image-version]\"\n    exit 1\n    ;;\nesac\n\n# Teardown\ndocker ps -q --filter \"network=appflowy-cloud_default\" | xargs -r docker stop\ndocker ps -aq --filter \"network=appflowy-cloud_default\" | xargs -r docker rm\ndocker compose down\n\n# Build or pull\nif [[ -z \"${SKIP_BUILD+x}\" ]]; then\n  # Use debug profile by default for faster builds (set RELEASE_BUILD=1 for optimized production builds)\n  if [[ -n \"${RELEASE_BUILD+x}\" ]]; then\n    echo \"Building with release profile (optimized for production)\"\n    BUILD_ARGS=\"--build-arg PROFILE=release\"\n  else\n    echo \"Building with debug profile for faster compilation (default)\"\n    BUILD_ARGS=\"--build-arg PROFILE=debug\"\n  fi\n  \n  # Build selected services (using default platform for better performance)\n  $BUILD_CLOUD && docker build $BUILD_ARGS \\\n    -t appflowyinc/appflowy_cloud:\"$IMAGE_VERSION\" \\\n    -f Dockerfile .\n  $BUILD_WORKER && docker build $BUILD_ARGS \\\n    -t appflowyinc/appflowy_worker:\"$IMAGE_VERSION\" \\\n    -f services/appflowy-worker/Dockerfile .\n  $BUILD_ADMIN_FRONTEND && docker build $BUILD_ARGS \\\n    -t appflowyinc/admin_frontend:\"$IMAGE_VERSION\" \\\n    -f admin_frontend/Dockerfile .\n\n  # Generate override for selected services\n  rm -f docker-compose.override.yml  # Clean up any existing override file\n  cat > docker-compose.override.yml <<EOF\nversion: '3'\nservices:\nEOF\n  \n  if $BUILD_CLOUD; then\n    cat >> docker-compose.override.yml <<EOF\n  appflowy_cloud:\n    image: appflowyinc/appflowy_cloud:$IMAGE_VERSION\nEOF\n  else\n    cat >> docker-compose.override.yml <<EOF\n  appflowy_cloud:\n    image: appflowyinc/appflowy_cloud:latest\nEOF\n  fi\n  \n  if $BUILD_WORKER; then\n    cat >> docker-compose.override.yml <<EOF\n  appflowy_worker:\n    image: appflowyinc/appflowy_worker:$IMAGE_VERSION\nEOF\n  else\n    cat >> docker-compose.override.yml <<EOF\n  appflowy_worker:\n    image: appflowyinc/appflowy_worker:latest\nEOF\n  fi\n  \n  if $BUILD_ADMIN_FRONTEND; then\n    cat >> docker-compose.override.yml <<EOF\n  admin_frontend:\n    image: appflowyinc/admin_frontend:$IMAGE_VERSION\nEOF\n  else\n    cat >> docker-compose.override.yml <<EOF\n  admin_frontend:\n    image: appflowyinc/admin_frontend:latest\nEOF\n  fi\n\n  export RUST_LOG=trace\n  docker compose -f docker-compose-ci.yml -f docker-compose.override.yml up -d\n  rm docker-compose.override.yml\n\n  # Update .env file with nginx proxy URLs for local testing\n  echo \"\"\n  echo \"Updating .env file with nginx proxy URLs for local testing...\"\n  \n  # Backup original .env if it doesn't have a backup already\n  if [[ ! -f \".env.backup\" ]]; then\n    cp .env .env.backup\n    echo \"Created backup: .env.backup\"\n  fi\n  \n  # Remove existing LOCALHOST_* variables and add new ones\n  grep -v \"^LOCALHOST_\" .env | grep -v \"# Local testing URLs (added by run_ci_server.sh)\" > .env.tmp || true\n  cat >> .env.tmp <<EOF\n\n# Local testing URLs (added by run_ci_server.sh)\nLOCALHOST_URL=http://localhost\nLOCALHOST_WS=ws://localhost/ws/v1\nLOCALHOST_WS_V2=ws://localhost/ws/v2\nLOCALHOST_GOTRUE=http://localhost/gotrue\nEOF\n  \n  mv .env.tmp .env\n  \n  echo \"Updated .env file with:\"\n  echo \"  LOCALHOST_URL=http://localhost\"\n  echo \"  LOCALHOST_WS=ws://localhost/ws/v1\"\n  echo \"  LOCALHOST_WS_V2=ws://localhost/ws/v2\"\n  echo \"  LOCALHOST_GOTRUE=http://localhost/gotrue\"\n  echo \"\"\n  echo \"You can now run your tests. The .env file has been updated.\"\n  echo \"To restore original settings, run: cp .env.backup .env\"\n\nelse\n  echo \"Skipping build; using existing images with tag $IMAGE_VERSION\"\n  export RUST_LOG=trace\n\n  # Set versions for compose pull\n  $BUILD_CLOUD && export APPFLOWY_CLOUD_VERSION=\"$IMAGE_VERSION\"\n  $BUILD_WORKER && export APPFLOWY_WORKER_VERSION=\"$IMAGE_VERSION\"\n  $BUILD_ADMIN_FRONTEND && export APPFLOWY_ADMIN_FRONTEND_VERSION=\"$IMAGE_VERSION\"\n\n  docker compose -f docker-compose-ci.yml pull\n\n  if $BUILD_CLOUD; then\n    echo \"appflowy_cloud image version:\"\n    docker images appflowyinc/appflowy_cloud --format \"{{.Repository}}:{{.Tag}} ({{.CreatedSince}}, {{.Size}})\"\n  fi\n\n  if $BUILD_WORKER; then\n    echo \"appflowy_worker image version:\"\n    docker images appflowyinc/appflowy_worker --format \"{{.Repository}}:{{.Tag}} ({{.CreatedSince}}, {{.Size}})\"\n  fi\n\n  if $BUILD_ADMIN_FRONTEND; then\n    echo \"admin_frontend image version:\"\n    docker images appflowyinc/admin_frontend --format \"{{.Repository}}:{{.Tag}} ({{.CreatedSince}}, {{.Size}})\"\n  fi\n\n  docker compose -f docker-compose-ci.yml up -d\n  \n  # Update .env file with nginx proxy URLs for local testing\n  echo \"\"\n  echo \"Updating .env file with nginx proxy URLs for local testing...\"\n  \n  # Backup original .env if it doesn't have a backup already\n  if [[ ! -f \".env.backup\" ]]; then\n    cp .env .env.backup\n    echo \"Created backup: .env.backup\"\n  fi\n  \n  # Remove existing LOCALHOST_* variables and add new ones\n  grep -v \"^LOCALHOST_\" .env | grep -v \"# Local testing URLs (added by run_ci_server.sh)\" > .env.tmp || true\n  cat >> .env.tmp <<EOF\n\n# Local testing URLs (added by run_ci_server.sh)\nLOCALHOST_URL=http://localhost\nLOCALHOST_WS=ws://localhost/ws/v1\nLOCALHOST_WS_V2=ws://localhost/ws/v2\nLOCALHOST_GOTRUE=http://localhost/gotrue\nEOF\n  \n  mv .env.tmp .env\n  \n  echo \"Updated .env file with:\"\n  echo \"  LOCALHOST_URL=http://localhost\"\n  echo \"  LOCALHOST_WS=ws://localhost/ws/v1\"\n  echo \"  LOCALHOST_WS_V2=ws://localhost/ws/v2\"\n  echo \"  LOCALHOST_GOTRUE=http://localhost/gotrue\"\n  echo \"\"\n  echo \"You can now run your tests. The .env file has been updated.\"\n  echo \"To restore original settings, run: cp .env.backup .env\"\nfi"
  },
  {
    "path": "script/run_local_server.sh",
    "content": "#!/usr/bin/env bash\n#\n# AppFlowy Cloud Local Development Server\n# =====================================\n#\n# Sets up and runs a complete local development environment for AppFlowy Cloud.\n# Includes PostgreSQL database, authentication services, and the AppFlowy Cloud application.\n#\n# USAGE:\n#   ./script/run_local_server.sh             # Normal run (no DB reset)\n#   ./script/run_local_server.sh --sqlx      # Run with SQLx preparation\n#   ./script/run_local_server.sh --reset     # Force database reset (no prompt)\n#   ./script/run_local_server.sh --reset --sqlx  # Reset DB and prepare SQLx\n#\n# PREREQUISITES:\n#   - Docker & Docker Compose\n#   - PostgreSQL client (psql)\n#   - Rust & Cargo toolchain\n#   - .env file (copy from dev.env)\n#   - sqlx-cli (will be installed automatically if missing)\n#\n# INTERACTIVE PROMPTS:\n#   - Stop existing containers? (default: no, data is preserved)\n#   - Install sqlx-cli? (if missing, default: yes)\n#\n# COMMAND LINE FLAGS:\n#   --sqlx     Prepare SQLx metadata (takes a few minutes)\n#   --reset    Reset database schema and data (no prompt)\n#\n# NOTE: Database setup (reset) only runs if --reset is provided.\n#\n# KEY ENVIRONMENT VARIABLES:\n#   SKIP_SQLX_PREPARE=true     # Skip SQLx preparation (faster restarts)\n#   SKIP_APPFLOWY_CLOUD=true   # Skip AppFlowy Cloud build\n#   SQLX_OFFLINE=false         # Connect to DB during build (default: true)\n#\n# TROUBLESHOOTING:\n#   - Missing .env: cp dev.env .env\n#   - Connection issues: Check Docker containers are running\n#   - Build errors: Ensure Rust toolchain is installed\n#   - SQLx errors: Run SQLx preparation or set SQLX_OFFLINE=false\n\nset -x\nset -eo pipefail\n\n# Parse command line arguments\nPREPARE_SQLX=false\nRESET_DB=false\nfor arg in \"$@\"; do\n    case $arg in\n        --sqlx)\n            PREPARE_SQLX=true\n            shift # Remove --sqlx from processing\n            ;;\n        --reset)\n            RESET_DB=true\n            shift # Remove --reset from processing\n            ;;\n        *)\n            echo -e \"${RED}Unknown argument: $arg${NC}\"\n            exit 1\n            ;;\n    esac\ndone\n\ncd \"$(dirname \"$0\")/..\"\n\n# Color codes for better visual output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nCYAN='\\033[0;36m'\nNC='\\033[0m' # No Color\n\n# Interactive prompt functions\ncheck_sqlx_cli() {\n    if ! command -v sqlx &> /dev/null; then\n        set +x\n        echo -e \"${RED}⚠️  sqlx-cli is not installed${NC}\"\n        echo -e \"${YELLOW}sqlx-cli is required for database operations.${NC}\"\n        if prompt_yes_no \"Install sqlx-cli now? (cargo install sqlx-cli)\" \"y\"; then\n            echo -e \"${BLUE}Installing sqlx-cli...${NC}\"\n            set -x\n            cargo install sqlx-cli\n            set +x\n            echo -e \"${GREEN}✓ sqlx-cli installed successfully${NC}\"\n            set -x\n            return 0\n        else\n            echo -e \"${RED}Cannot proceed without sqlx-cli. Exiting.${NC}\"\n            exit 1\n        fi\n    fi\n}\n\nprompt_yes_no() {\n    local message=\"$1\"\n    local default=\"$2\"\n    \n    # Temporarily disable command printing\n    set +x\n    \n    while true; do\n        if [[ \"$default\" == \"y\" ]]; then\n            echo -e \"${CYAN}$message [Y/n]:${NC} \\c\"\n            read response\n            response=${response:-y}\n        else\n            echo -e \"${CYAN}$message [y/N]:${NC} \\c\"\n            read response\n            response=${response:-n}\n        fi\n        \n        case $response in\n            [Yy]* ) set -x; return 0;;\n            [Nn]* ) set -x; return 1;;\n            * ) echo -e \"${YELLOW}Please answer yes (y) or no (n).${NC}\";;\n        esac\n    done\n}\n\nshow_banner() {\n    # Temporarily disable command printing\n    set +x\n    echo -e \"${BLUE}==============================================\"\n    echo -e \"  AppFlowy Cloud Local Development Setup\"\n    echo -e \"===============================================${NC}\"\n    echo \"\"\n    set -x\n}\n\nDB_USER=\"${POSTGRES_USER:=postgres}\"\nDB_PASSWORD=\"${POSTGRES_PASSWORD:=password}\"\nDB_PORT=\"${POSTGRES_PORT:=5432}\"\nDB_HOST=\"${POSTGRES_HOST:=localhost}\"\n\n# Enable SQLX offline mode by default for faster and more reliable builds\nexport SQLX_OFFLINE=\"${SQLX_OFFLINE:=true}\"\n\nshow_banner\n\nif [ ! -f .env ]; then\n    echo \".env file not found in the current directory. Try: cp dev.env .env\"\n    exit 1\nfi\n\n# Stop and remove existing containers\nif [[ \"$RESET_DB\" == \"true\" ]]; then\n    # When --reset is used, automatically stop and remove containers\n    echo -e \"${YELLOW}Stopping and removing existing containers (--reset used)...${NC}\"\n    docker compose --file ./docker-compose-dev.yml down\n    echo -e \"${GREEN}✓ Containers stopped and removed (database data is preserved in Docker volume)${NC}\"\nelif prompt_yes_no \"Stop and remove existing containers? (Data will be preserved)\" \"n\"; then\n    echo -e \"${YELLOW}Stopping and removing existing containers...${NC}\"\n    docker compose --file ./docker-compose-dev.yml down\n    echo -e \"${GREEN}✓ Containers stopped and removed (database data is preserved in Docker volume)${NC}\"\nelse\n    echo -e \"${YELLOW}Keeping existing containers running.${NC}\"\n    echo -e \"${BLUE}Tip: You can manually stop containers with: docker compose --file ./docker-compose-dev.yml down${NC}\"\nfi\n\necho \"\"\necho \"Starting Docker Compose services...\"\n\n# Start the Docker Compose setup\nexport GOTRUE_MAILER_AUTOCONFIRM=true\n\n# Enable Google OAuth when running locally\nexport GOTRUE_EXTERNAL_GOOGLE_ENABLED=true\n\ndocker compose --file ./docker-compose-dev.yml up -d --build\n\n# Keep pinging Postgres until it's ready to accept commands\nATTEMPTS=0\nMAX_ATTEMPTS=30  # Adjust this value based on your needs\nuntil PGPASSWORD=\"${DB_PASSWORD}\" psql -h \"${DB_HOST}\" -U \"${DB_USER}\" -p \"${DB_PORT}\" -d \"postgres\" -c '\\q' || [ $ATTEMPTS -eq $MAX_ATTEMPTS ]; do\n  >&2 echo \"Postgres is still unavailable - sleeping\"\n  sleep 1\n  ATTEMPTS=$((ATTEMPTS+1))\ndone\n\nif [ $ATTEMPTS -eq $MAX_ATTEMPTS ]; then\n  >&2 echo \"Failed to connect to Postgres after $MAX_ATTEMPTS attempts, exiting.\"\n  exit 1\nfi\n\nuntil curl localhost:9999/health; do\n  sleep 1\ndone\n\n# Generate protobuf files for collab-rt-entity crate.\n# To run sqlx prepare, we need to build the collab-rt-entity crate first\n./script/code_gen.sh\n\necho \"\"\nif [[ \"$RESET_DB\" == \"true\" ]]; then\n    check_sqlx_cli\n    set +x\n    echo -e \"${YELLOW}Setting up database...${NC}\"\n    echo -e \"${RED}Warning: This will reset the database schema and clear existing data in affected tables${NC}\"\n    echo -e \"${BLUE}Creating database and running migrations (--reset used)...${NC}\"\n    set -x\n    cargo sqlx database create && cargo sqlx migrate run\n    set +x\n    echo -e \"${GREEN}✓ Database setup completed${NC}\"\n    set -x\nfi\n\necho \"\"\n# Interactive prompt for SQLx preparation (unless explicitly skipped)\nif [[ -z \"${SKIP_SQLX_PREPARE+x}\" ]]; then\n    if [[ \"$PREPARE_SQLX\" == \"true\" ]]; then\n        check_sqlx_cli\n        set +x\n        echo -e \"${BLUE}Preparing SQLx metadata...${NC}\"\n        set -x\n        cargo sqlx prepare --workspace\n        set +x\n        echo -e \"${GREEN}✓ SQLx preparation completed${NC}\"\n        set -x\n    else\n        set +x\n        echo -e \"${YELLOW}Skipping SQLx preparation. Use --sqlx flag to prepare SQLx metadata.${NC}\"\n        set -x\n    fi\nelse\n    set +x\n    echo -e \"${YELLOW}SQLx preparation skipped (SKIP_SQLX_PREPARE is set)${NC}\"\n    set -x\nfi\n\necho \"\"\nset +x\necho -e \"${GREEN}==============================================\"\necho -e \"✓ AppFlowy Cloud Local Development Setup Complete!\"\necho -e \"==============================================${NC}\"\necho \"\"\necho -e \"${CYAN}Services running:${NC}\"\necho -e \"  ${YELLOW}• PostgreSQL Database:${NC} ${BLUE}localhost:${DB_PORT}${NC}\"\necho -e \"  ${YELLOW}• AppFlowy Cloud API:${NC} ${BLUE}localhost:9999${NC}\"\necho -e \"  ${YELLOW}• Authentication Service:${NC} ${BLUE}(Check docker-compose-dev.yml for ports)${NC}\"\necho \"\"\necho -e \"${CYAN}Build configuration:${NC}\"\necho -e \"  ${YELLOW}• SQLX_OFFLINE:${NC} ${BLUE}${SQLX_OFFLINE}${NC} (offline mode for faster builds)\"\necho \"\"\necho -e \"${CYAN}To stop all services:${NC} ${BLUE}docker compose --file ./docker-compose-dev.yml down${NC}\"\necho -e \"${CYAN}To view logs:${NC} ${BLUE}docker compose --file ./docker-compose-dev.yml logs -f${NC}\"\necho \"\"\nset -x\n\n# Build AppFlowy Cloud (unless explicitly skipped)\nif [[ -z \"${SKIP_APPFLOWY_CLOUD+x}\" ]]; then\n    set +x\n    echo -e \"${BLUE}Building AppFlowy Cloud...${NC}\"\n    set -x\n    cargo run --package xtask\n    set +x\n    echo -e \"${GREEN}✓ AppFlowy Cloud build completed${NC}\"\n    set -x\nelse\n    set +x\n    echo -e \"${YELLOW}AppFlowy Cloud build skipped (SKIP_APPFLOWY_CLOUD is set)${NC}\"\n    set -x\nfi\n\n\n# revert to require signup email verification\nexport GOTRUE_MAILER_AUTOCONFIRM=false\ndocker compose --file ./docker-compose-dev.yml up -d"
  },
  {
    "path": "services/appflowy-collaborate/Cargo.toml",
    "content": "[package]\nname = \"appflowy-collaborate\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[dependencies]\nappflowy-proto.workspace = true\naccess-control.workspace = true\nactix.workspace = true\nactix-web-actors = { version = \"4.3\" }\nactix-http.workspace = true\napp-error = { workspace = true, features = [\n  \"sqlx_error\",\n  \"actix_web_error\",\n  \"tokio_error\",\n  \"bincode_error\",\n  \"appflowy_ai_error\",\n] }\nbrotli.workspace = true\ndashmap.workspace = true\nasync-stream.workspace = true\nfutures.workspace = true\ntracing = \"0.1.40\"\nfutures-util = \"0.3.30\"\ntokio-util = { version = \"0.7\", features = [\"codec\"] }\ntokio-stream = { version = \"0.1.14\", features = [\"sync\"] }\ntokio = { workspace = true, features = [\n  \"net\",\n  \"sync\",\n  \"macros\",\n  \"rt-multi-thread\",\n] }\nasync-trait.workspace = true\nprost.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nserde_repr.workspace = true\nsqlx = { workspace = true, default-features = false, features = [\n  \"runtime-tokio-rustls\",\n  \"macros\",\n  \"postgres\",\n  \"uuid\",\n  \"chrono\",\n] }\nthiserror = \"1.0.56\"\nanyhow.workspace = true\nbytes.workspace = true\narc-swap.workspace = true\n\ncollab = { workspace = true }\ncollab-entity = { workspace = true }\ncollab-folder = { workspace = true }\ncollab-document = { workspace = true }\ncollab-stream = { workspace = true }\ndatabase.workspace = true\ndatabase-entity.workspace = true\ngovernor = { version = \"0.6.3\" }\nyrs.workspace = true\nchrono = \"0.4.31\"\ncollab-rt-entity = { workspace = true, features = [\"actix_message\"] }\ncollab-rt-protocol.workspace = true\nuuid = { version = \"1\", features = [\"v4\"] }\nprometheus-client = \"0.22.1\"\nsemver = \"1.0.22\"\nredis = { version = \"0.29\", features = [\n  \"uuid\",\n  \"bytes\",\n  \"tokio-comp\",\n  \"aio\",\n  \"connection-manager\",\n] }\nsecrecy.workspace = true\nlazy_static = \"1.4.0\"\nitertools = \"0.12.0\"\nvalidator.workspace = true\nrayon.workspace = true\nzstd.workspace = true\nindexer.workspace = true\ninfra = { workspace = true }\n\n[dev-dependencies]\nrand = \"0.8.5\"\nworkspace-template.workspace = true\nunicode-normalization = \"0.1.24\"\nfutures = \"0.3\"\nnanoid = \"0.4.0\"\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/client/mod.rs",
    "content": "pub mod rt_client;\npub use crate::actix_ws::client::rt_client::*;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/client/rt_client.rs",
    "content": "use crate::actix_ws::entities::{ClientWebSocketMessage, Connect, Disconnect, RealtimeMessage};\nuse crate::error::RealtimeError;\nuse crate::RealtimeClientWebsocketSink;\nuse actix::{\n  fut, Actor, ActorContext, ActorFutureExt, Addr, AsyncContext, Context, ContextFutureSpawner,\n  Handler, MailboxError, Recipient, Running, StreamHandler, WrapFuture,\n};\nuse actix_web_actors::ws;\nuse actix_web_actors::ws::{CloseCode, CloseReason, ProtocolError, WebsocketContext};\nuse anyhow::anyhow;\nuse async_trait::async_trait;\nuse bytes::Bytes;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::SystemMessage;\nuse governor::clock::DefaultClock;\nuse governor::middleware::NoOpMiddleware;\nuse governor::state::{InMemoryState, NotKeyed};\nuse governor::{Quota, RateLimiter};\nuse semver::Version;\nuse std::num::NonZeroU32;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::sync::mpsc;\nuse tokio::time::sleep;\nuse tracing::{debug, error, trace, warn};\n\npub type HandlerResult = anyhow::Result<(), RealtimeError>;\npub trait RealtimeServer:\n  Actor<Context = Context<Self>>\n  + Handler<ClientWebSocketMessage, Result = HandlerResult>\n  + Handler<Connect, Result = HandlerResult>\n  + Handler<Disconnect, Result = HandlerResult>\n{\n}\n\ntype BinaryRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock, NoOpMiddleware>;\npub struct RealtimeClient<S: RealtimeServer> {\n  user: RealtimeUser,\n  hb: Instant,\n  pub server: Addr<S>,\n  heartbeat_interval: Duration,\n  client_timeout: Duration,\n  external_source: Option<mpsc::Receiver<RealtimeMessage>>,\n  #[allow(dead_code)]\n  /// Indicates the version of the client, used for compatibility checks with the server.\n  client_version: Version,\n  /// To prevent overwhelming the server with too many messages at once, each client has a rate-limiting\n  /// mechanism. This limits the number of messages a client can send per second, ensuring the server's\n  /// mailbox does not get full from receiving too many messages at the same time.\n  binary_rate_limiter: Arc<BinaryRateLimiter>,\n}\n\nimpl<S> RealtimeClient<S>\nwhere\n  S: RealtimeServer,\n{\n  pub fn new(\n    user: RealtimeUser,\n    server: Addr<S>,\n    heartbeat_interval: Duration,\n    client_timeout: Duration,\n    client_version: Version,\n    external_source: mpsc::Receiver<RealtimeMessage>,\n    rate_limit_times_per_sec: u32,\n  ) -> Self {\n    let rate_limiter = gen_rate_limiter(rate_limit_times_per_sec);\n    Self {\n      user,\n      hb: Instant::now(),\n      server,\n      heartbeat_interval,\n      client_timeout,\n      external_source: Some(external_source),\n      client_version,\n      binary_rate_limiter: Arc::new(rate_limiter),\n    }\n  }\n\n  fn hb(&self, ctx: &mut ws::WebsocketContext<Self>) {\n    ctx.run_interval(self.heartbeat_interval, move |act, ctx| {\n      if Instant::now().duration_since(act.hb) > act.client_timeout {\n        let user = act.user.clone();\n        warn!(\n          \"User {} heartbeat failed, exceeding timeout limit of {} secs. Disconnecting!\",\n          user,\n          act.client_timeout.as_secs()\n        );\n\n        act.server.do_send(Disconnect { user });\n        ctx.stop();\n        return;\n      }\n\n      ctx.ping(b\"\");\n    });\n  }\n\n  pub fn try_send(&self, message: RealtimeMessage) -> Result<(), RealtimeError> {\n    if self.binary_rate_limiter.check().is_err() {\n      trace!(\"Rate limit exceeded for user: {}\", self.user);\n      return Err(RealtimeError::TooManyMessage(self.user.to_string()));\n    }\n\n    self\n      .server\n      .try_send(ClientWebSocketMessage {\n        user: self.user.clone(),\n        message,\n      })\n      .map_err(|err| RealtimeError::SendWSMessageFailed(err.to_string()))\n  }\n}\n\nimpl<S> RealtimeClient<S>\nwhere\n  S: RealtimeServer,\n{\n  fn handle_binary(&mut self, ctx: &mut WebsocketContext<RealtimeClient<S>>, bytes: Bytes) {\n    // Immediately return if rate limit is exceeded.\n    if let Err(e) = self.binary_rate_limiter.check() {\n      trace!(\"Rate limit exceeded for user: {}, error: {}\", self.user, e);\n      return;\n    }\n    let server = self.server.clone();\n    let user = self.user.clone();\n\n    let fut = async move {\n      match tokio::task::spawn_blocking(move || RealtimeMessage::decode(&bytes)).await {\n        Ok(Ok(decoded_message)) => {\n          let mut client_message = Some(ClientWebSocketMessage {\n            user,\n            message: decoded_message,\n          });\n\n          let mut attempts = 0;\n          const MAX_RETRIES: usize = 3;\n          const RETRY_DELAY: Duration = Duration::from_millis(500);\n          while attempts < MAX_RETRIES {\n            if let Some(message_to_send) = client_message.take() {\n              match server.try_send(message_to_send) {\n                Ok(_) => return Ok(()),\n                Err(err) if attempts < MAX_RETRIES - 1 => {\n                  client_message = Some(err.into_inner());\n                  attempts += 1;\n                  sleep(RETRY_DELAY).await;\n                },\n                Err(err) => {\n                  return Err(anyhow!(\n                    \"Failed to send message to server after retries: {:?}\",\n                    err\n                  ));\n                },\n              }\n            } else {\n              return Err(anyhow!(\"Unexpected empty client message\"));\n            }\n          }\n          Ok(())\n        },\n        Ok(Err(decode_err)) => Err(anyhow!(\"Error decoding message: {}\", decode_err)),\n        Err(spawn_err) => Err(anyhow!(\"Error spawning blocking task: {}\", spawn_err)),\n      }\n    };\n\n    let act_fut = fut::wrap_future::<_, Self>(fut);\n    ctx.spawn(act_fut.map(|res, _act, _ctx| {\n      if let Err(e) = res {\n        error!(\"{}\", e);\n      }\n    }));\n  }\n\n  fn handle_ping(&mut self, ctx: &mut WebsocketContext<RealtimeClient<S>>, msg: &Bytes) {\n    self.hb = Instant::now();\n    ctx.pong(msg);\n  }\n\n  fn handle_close(\n    &mut self,\n    ctx: &mut WebsocketContext<RealtimeClient<S>>,\n    reason: Option<CloseReason>,\n  ) {\n    debug!(\"Websocket closing for ({:?}): {:?}\", self.user.uid, reason);\n    ctx.close(reason);\n    ctx.stop();\n  }\n}\n\nimpl<S> Actor for RealtimeClient<S>\nwhere\n  S: RealtimeServer,\n{\n  type Context = ws::WebsocketContext<Self>;\n\n  fn started(&mut self, ctx: &mut Self::Context) {\n    self.hb(ctx);\n    let recipient = ctx.address().recipient();\n    if let Some(mut external_source) = self.external_source.take() {\n      actix::spawn(async move {\n        while let Some(message) = external_source.recv().await {\n          // Attempt to send the constructed message to the recipient. Handle errors appropriately,\n          // notably breaking out of the loop if the recipient's mailbox is closed, indicating\n          // that no further messages can be sent.\n          if let Err(err) = recipient.send(message).await {\n            match err {\n              MailboxError::Closed => {\n                // If the recipient's mailbox is closed, stop listening for further notifications.\n                break;\n              },\n              MailboxError::Timeout => {\n                // Log a timeout error if the message could not be sent within the expected time frame.\n                error!(\"User change message recipient send timeout\");\n              },\n            }\n          }\n        }\n      });\n    } else {\n      error!(\"External source is empty, it should be only called once\");\n    }\n\n    // Asynchronously sends a `Connect` message to the server actor, indicating a new websocket connection attempt.\n    // The `Connect` message includes the address of the current actor (as a recipient for further communication)\n    // and the user details associated with this connection attempt.\n    self.server\n        .send(Connect {\n          socket: ctx.address().recipient(),\n          user: self.user.clone(),\n        })\n        // Converts the future into an actor future, allowing it to be handled within the actor context.\n        .into_actor(self)\n        .then(|res, _session, ctx| {\n          match res {\n            // In case of successful connection acknowledgement from the server,\n            // log a trace message indicating the successful connection attempt.\n            Ok(Ok(_)) => {\n              trace!(\"WebSocket client successfully sent connect message to server.\")\n            },\n            // If the server responded with an error, or sending the message resulted in an error,\n            // log the error and stop the current actor.\n            Ok(Err(err)) => {\n              error!(\"ws client send connect message to server error: {:?}\", err);\n              ctx.stop();\n            },\n            Err(err) => {\n              error!(\"ws client send connect message to server error: {:?}\", err);\n              ctx.stop();\n            },\n          }\n          fut::ready(())\n        })\n        // Await the completion of the above future before proceeding, ensuring that the actor processes\n        // it in sequence. This is necessary for handling the asynchronous send operation within the actor's context.\n        .wait(ctx);\n  }\n\n  fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {\n    // When the user is None which means the user is kicked off by the server, do not send\n    // disconnect message to the server.\n    let user = self.user.clone();\n    trace!(\"{} stopping websocket connect\", user);\n    self.server.do_send(Disconnect { user });\n    Running::Stop\n  }\n}\n\n/// Handle message sent from the server\nimpl<S> Handler<RealtimeMessage> for RealtimeClient<S>\nwhere\n  S: RealtimeServer,\n{\n  type Result = ();\n\n  fn handle(&mut self, message: RealtimeMessage, ctx: &mut Self::Context) {\n    match message.encode() {\n      Ok(data) => ctx.binary(Bytes::from(data)),\n      Err(err) => error!(\"Error encoding message: {}\", err),\n    }\n\n    if let RealtimeMessage::System(SystemMessage::DuplicateConnection) = &message {\n      let reason = CloseReason {\n        code: CloseCode::Normal,\n        description: Some(\"Duplicate connection\".to_string()),\n      };\n      ctx.close(Some(reason));\n    }\n  }\n}\n\n/// Handle the messages sent from the client\nimpl<S> StreamHandler<Result<ws::Message, ws::ProtocolError>> for RealtimeClient<S>\nwhere\n  S: RealtimeServer,\n{\n  fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {\n    if let Err(err) = &msg {\n      if let ProtocolError::Overflow = err {\n        ctx.stop();\n      }\n      return;\n    }\n\n    match msg.unwrap() {\n      ws::Message::Ping(msg) => self.handle_ping(ctx, &msg),\n      ws::Message::Pong(_) => self.hb = Instant::now(),\n      ws::Message::Text(_) => {},\n      ws::Message::Binary(bytes) => self.handle_binary(ctx, bytes),\n      ws::Message::Close(reason) => self.handle_close(ctx, reason),\n      ws::Message::Continuation(_) => {},\n      ws::Message::Nop => (),\n    }\n  }\n}\n\n#[derive(Clone)]\npub struct RealtimeClientWebsocketSinkImpl(pub Recipient<RealtimeMessage>);\n\n#[async_trait]\nimpl RealtimeClientWebsocketSink for RealtimeClientWebsocketSinkImpl {\n  fn do_send(&self, message: RealtimeMessage) {\n    self.0.do_send(message);\n  }\n}\n\nfn gen_rate_limiter(\n  mut times_per_sec: u32,\n) -> RateLimiter<NotKeyed, InMemoryState, DefaultClock, NoOpMiddleware> {\n  if times_per_sec == 0 {\n    times_per_sec = 1;\n  }\n  let quota = Quota::per_second(NonZeroU32::new(times_per_sec).unwrap());\n  RateLimiter::direct(quota)\n}\n\n#[cfg(test)]\nmod tests {\n  use std::time::Duration;\n  use tokio::time::sleep;\n\n  #[tokio::test]\n  async fn rate_limit_test() {\n    let rate_limiter = super::gen_rate_limiter(10);\n    for i in 0..=10 {\n      if i == 10 {\n        assert!(rate_limiter.check().is_err());\n      } else {\n        assert!(rate_limiter.check().is_ok());\n      }\n    }\n    assert!(rate_limiter.check().is_err());\n    assert!(rate_limiter.check().is_err());\n\n    sleep(Duration::from_secs(1)).await;\n    for i in 0..=10 {\n      if i == 10 {\n        assert!(rate_limiter.check().is_err());\n      } else {\n        assert!(rate_limiter.check().is_ok());\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/entities.rs",
    "content": "use crate::error::RealtimeError;\nuse actix::{Message, Recipient};\nuse app_error::AppError;\n\nuse bytes::Bytes;\nuse collab_entity::CollabType;\nuse collab_rt_entity::user::RealtimeUser;\npub use collab_rt_entity::RealtimeMessage;\nuse serde_repr::{Deserialize_repr, Serialize_repr};\nuse std::fmt::Debug;\nuse uuid::Uuid;\n\n#[derive(Debug, Message, Clone)]\n#[rtype(result = \"Result<(), RealtimeError>\")]\npub struct Connect {\n  pub socket: Recipient<RealtimeMessage>,\n  pub user: RealtimeUser,\n}\n\n#[derive(Debug, Message, Clone)]\n#[rtype(result = \"Result<(), RealtimeError>\")]\npub struct Disconnect {\n  pub user: RealtimeUser,\n}\n\n#[derive(Debug, Message, Clone)]\n#[rtype(result = \"Result<(), RealtimeError>\")]\npub struct DisconnectByServer;\n#[derive(Debug, Clone, Serialize_repr, Deserialize_repr)]\n#[repr(u8)]\npub enum BusinessID {\n  CollabId = 1,\n}\n\n#[derive(Debug, Message, Clone)]\n#[rtype(result = \"Result<(), RealtimeError>\")]\npub struct ClientWebSocketMessage {\n  pub user: RealtimeUser,\n  pub message: RealtimeMessage,\n}\n\n#[derive(Message)]\n#[rtype(result = \"Result<(), RealtimeError>\")]\npub struct ClientHttpStreamMessage {\n  pub uid: i64,\n  pub device_id: String,\n  pub message: RealtimeMessage,\n}\n\n#[derive(Message)]\n#[rtype(result = \"Result<(), AppError>\")]\npub struct ClientHttpUpdateMessage {\n  pub user: RealtimeUser,\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n  /// Encoded yrs::Update or doc state\n  pub update: Bytes,\n  /// If the state_vector is not None, it will calculate missing updates base on\n  /// given state_vector after apply the update\n  pub state_vector: Option<Bytes>,\n  pub collab_type: CollabType,\n  /// If return_tx is Some, calling await on its receiver will wait until the update was applied\n  /// to the collab. The return value will be None if the input state_vector is None.\n  pub return_tx: Option<tokio::sync::oneshot::Sender<Result<Option<Vec<u8>>, AppError>>>,\n}\n\n#[derive(Message)]\n#[rtype(result = \"Result<(), AppError>\")]\npub struct ClientGenerateEmbeddingMessage {\n  pub workspace_id: Uuid,\n  pub object_id: Uuid,\n  pub return_tx: Option<tokio::sync::oneshot::Sender<Result<(), AppError>>>,\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/mod.rs",
    "content": "pub mod client;\npub mod entities;\npub mod server;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/server/mod.rs",
    "content": "mod rt_actor;\npub use rt_actor::*;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/actix_ws/server/rt_actor.rs",
    "content": "use std::ops::Deref;\n\nuse crate::error::RealtimeError;\nuse crate::CollaborationServer;\nuse actix::{Actor, Context, Handler};\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse collab_rt_entity::user::UserDevice;\nuse tracing::{error, info, trace, warn};\n\nuse crate::actix_ws::client::rt_client::{RealtimeClientWebsocketSinkImpl, RealtimeServer};\nuse crate::actix_ws::entities::{\n  ClientGenerateEmbeddingMessage, ClientHttpStreamMessage, ClientHttpUpdateMessage,\n  ClientWebSocketMessage, Connect, Disconnect,\n};\n\n#[derive(Clone)]\npub struct RealtimeServerActor(pub CollaborationServer);\n\nimpl RealtimeServer for RealtimeServerActor {}\n\nimpl Deref for RealtimeServerActor {\n  type Target = CollaborationServer;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl Actor for RealtimeServerActor {\n  type Context = Context<Self>;\n\n  fn started(&mut self, ctx: &mut Self::Context) {\n    let mail_box_size = mail_box_size();\n    info!(\n      \"realtime server started with mailbox size: {}\",\n      mail_box_size\n    );\n    ctx.set_mailbox_capacity(mail_box_size);\n  }\n}\nimpl actix::Supervised for RealtimeServerActor {\n  fn restarting(&mut self, ctx: &mut Context<RealtimeServerActor>) {\n    error!(\"realtime server is restarting\");\n    ctx.set_mailbox_capacity(mail_box_size());\n  }\n}\n\nfn mail_box_size() -> usize {\n  match std::env::var(\"APPFLOWY_WEBSOCKET_MAILBOX_SIZE\") {\n    Ok(value) => value.parse::<usize>().unwrap_or_else(|_| {\n      error!(\"Error: Invalid mailbox size format, defaulting to 6000\");\n      6000\n    }),\n    Err(_) => 6000,\n  }\n}\n\nimpl Handler<Connect> for RealtimeServerActor {\n  type Result = anyhow::Result<(), RealtimeError>;\n\n  fn handle(&mut self, new_conn: Connect, _ctx: &mut Context<Self>) -> Self::Result {\n    let conn_sink = RealtimeClientWebsocketSinkImpl(new_conn.socket);\n    trace!(\n      \"New connection from user: {}, device: {}\",\n      new_conn.user.uid,\n      new_conn.user.device_id\n    );\n    self.handle_new_connection(new_conn.user, conn_sink)\n  }\n}\n\nimpl Handler<Disconnect> for RealtimeServerActor {\n  type Result = anyhow::Result<(), RealtimeError>;\n  fn handle(&mut self, msg: Disconnect, _: &mut Context<Self>) -> Self::Result {\n    self.handle_disconnect(msg.user)\n  }\n}\n\nimpl Handler<ClientWebSocketMessage> for RealtimeServerActor {\n  type Result = anyhow::Result<(), RealtimeError>;\n\n  fn handle(\n    &mut self,\n    client_msg: ClientWebSocketMessage,\n    _ctx: &mut Context<Self>,\n  ) -> Self::Result {\n    let ClientWebSocketMessage { user, message } = client_msg;\n    match message.split_messages_by_object_id() {\n      Ok(message_by_object_id) => self.handle_client_message(user, message_by_object_id),\n      Err(err) => {\n        if cfg!(debug_assertions) {\n          error!(\"parse client message error: {}\", err);\n        }\n        Ok(())\n      },\n    }\n  }\n}\n\nimpl Handler<ClientHttpStreamMessage> for RealtimeServerActor {\n  type Result = anyhow::Result<(), RealtimeError>;\n\n  fn handle(\n    &mut self,\n    client_msg: ClientHttpStreamMessage,\n    _ctx: &mut Context<Self>,\n  ) -> Self::Result {\n    let ClientHttpStreamMessage {\n      uid,\n      device_id,\n      message,\n    } = client_msg;\n\n    // Get the real-time user by the device ID and user ID. If the user is not found, which means\n    // the user is not connected to the real-time server via websocket.\n    let user = self.get_user_by_device(&UserDevice::new(&device_id, uid));\n    match (user, message.split_messages_by_object_id()) {\n      (Some(user), Ok(messages)) => self.handle_client_message(user, messages),\n      (None, _) => {\n        warn!(\"Can't find the realtime user uid:{}, device:{}. User should connect via websocket before\", uid,device_id);\n        Ok(())\n      },\n      (Some(_), Err(err)) => {\n        if cfg!(debug_assertions) {\n          error!(\"parse client message error: {}\", err);\n        }\n        Ok(())\n      },\n    }\n  }\n}\n\nimpl Handler<ClientHttpUpdateMessage> for RealtimeServerActor {\n  type Result = Result<(), AppError>;\n\n  fn handle(&mut self, msg: ClientHttpUpdateMessage, _ctx: &mut Self::Context) -> Self::Result {\n    trace!(\"Receive client http update message\");\n    self\n      .handle_client_http_update(msg)\n      .map_err(|err| AppError::Internal(anyhow!(\"handle client http message error: {}\", err)))?;\n    Ok(())\n  }\n}\n\nimpl Handler<ClientGenerateEmbeddingMessage> for RealtimeServerActor {\n  type Result = Result<(), AppError>;\n\n  fn handle(\n    &mut self,\n    msg: ClientGenerateEmbeddingMessage,\n    _ctx: &mut Self::Context,\n  ) -> Self::Result {\n    self\n      .handle_client_generate_embedding_request(msg)\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n          \"handle client generate embedding request error: {}\",\n          err\n        ))\n      })?;\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/client/client_msg_router.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse access_control::collab::RealtimeAccessControl;\nuse async_trait::async_trait;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::ClientCollabMessage;\nuse collab_rt_entity::{MessageByObjectId, RealtimeMessage};\nuse tokio_stream::wrappers::{BroadcastStream, ReceiverStream};\nuse tokio_stream::StreamExt;\nuse tracing::{error, trace};\nuse uuid::Uuid;\n\nuse crate::util::channel_ext::UnboundedSenderSink;\n\n#[async_trait]\npub trait RealtimeClientWebsocketSink: Send + Sync + 'static {\n  fn do_send(&self, message: RealtimeMessage);\n}\n\n/// Manages message routing for client connections in a collaborative environment.\n///\n/// acts as an intermediary that receives messages from individual client sessions and\n/// forwards them to the appropriate destination, either by broadcasting to all connected\n/// clients or directing them to a specific client. It leverages a websocket sink for outgoing\n/// messages and a broadcast channel to receive incoming messages from the collab server.\npub struct ClientMessageRouter {\n  pub(crate) sink: Arc<dyn RealtimeClientWebsocketSink>,\n  /// Used to receive messages from the collab server. The message will forward to the [CollabBroadcast] which\n  /// will broadcast the message to all connected clients.\n  ///\n  /// The message flow:\n  /// ClientSession(websocket) -> [CollabRealtimeServer] -> [ClientMessageRouter] -> [CollabBroadcast] 1->* websocket(client)\n  pub(crate) stream_tx: tokio::sync::broadcast::Sender<MessageByObjectId>,\n}\n\nimpl ClientMessageRouter {\n  pub fn new(sink: impl RealtimeClientWebsocketSink) -> Self {\n    // When receive a new connection, create a new [ClientStream] that holds the connection's websocket\n    let (stream_tx, _) = tokio::sync::broadcast::channel(1000);\n    Self {\n      sink: Arc::new(sink),\n      stream_tx,\n    }\n  }\n\n  /// Initializes a communication channel for a client and a specific collaboration object.\n  ///\n  /// sets up a two-way communication channel between the client and the collaboration server,\n  /// tailored for a specific object identified by `object_id`. It establishes a mechanism for sending changes to\n  /// and receiving updates from the collaboration object, ensuring that only authorized changes are communicated.\n  ///\n  /// - An `UnboundedSenderSink<T>`, which is used to send updates to the connected client.\n  /// - A `ReceiverStream<MessageByObjectId>`, which is used to receive authorized updates from the connected client.\n  ///\n  pub fn init_client_communication<T>(\n    &mut self,\n    workspace_id: Uuid,\n    user: &RealtimeUser,\n    object_id: Uuid,\n    access_control: Arc<dyn RealtimeAccessControl>,\n  ) -> (UnboundedSenderSink<T>, ReceiverStream<MessageByObjectId>)\n  where\n    T: Into<RealtimeMessage> + Send + Sync + 'static,\n  {\n    let client_ws_sink = self.sink.clone();\n    let mut stream_rx = BroadcastStream::new(self.stream_tx.subscribe());\n\n    // Send the message to the connected websocket client. When the client receive the message,\n    // it will apply the changes.\n    let (client_sink_tx, mut client_sink_rx) = tokio::sync::mpsc::unbounded_channel::<T>();\n    let sink_access_control = access_control.clone();\n    let uid = user.uid;\n    let client_sink = UnboundedSenderSink::<T>::new(client_sink_tx);\n    tokio::spawn(async move {\n      while let Some(msg) = client_sink_rx.recv().await {\n        let result = sink_access_control\n          .can_read_collab(&workspace_id, &uid, &object_id)\n          .await;\n        match result {\n          Ok(is_allowed) => {\n            if is_allowed {\n              let rt_msg = msg.into();\n              client_ws_sink.do_send(rt_msg);\n            } else {\n              trace!(\"user:{} is not allowed to read {}\", uid, object_id);\n              tokio::time::sleep(Duration::from_secs(2)).await;\n            }\n          },\n          Err(err) => {\n            error!(\"user:{} fail to receive updates: {}\", uid, err);\n            tokio::time::sleep(Duration::from_secs(1)).await;\n          },\n        }\n      }\n    });\n    let user = user.clone();\n    // stream_rx continuously receive messages from the websocket client and then\n    // forward the message to the subscriber which is the broadcast channel [CollabBroadcast].\n    let (client_msg_rx, rx) = tokio::sync::mpsc::channel(100);\n    let client_stream = ReceiverStream::new(rx);\n    tokio::spawn(async move {\n      let target_object_id = object_id.to_string();\n      while let Some(Ok(messages_by_oid)) = stream_rx.next().await {\n        for (message_object_id, original_messages) in messages_by_oid.into_inner() {\n          // if the message is not for the target object, skip it. The stream_rx receives different\n          // objects' messages, so we need to filter out the messages that are not for the target object.\n          if target_object_id != message_object_id {\n            continue;\n          }\n\n          // before applying user messages, we need to check if the user has the permission\n          // valid_messages contains the messages that the user is allowed to apply\n          // invalid_message contains the messages that the user is not allowed to apply\n          let (valid_messages, _) = Self::access_control(\n            &workspace_id,\n            &user.uid,\n            &object_id,\n            access_control.clone(),\n            original_messages,\n          )\n          .await;\n          if valid_messages.is_empty() {\n            continue;\n          }\n\n          // if tx.send return error, it means the client is disconnected from the group\n          if let Err(err) = client_msg_rx\n            .send(MessageByObjectId::new_with_message(\n              message_object_id,\n              valid_messages,\n            ))\n            .await\n          {\n            trace!(\n              \"{} send message to user:{} stream fail with error: {}, break the loop\",\n              target_object_id,\n              user.user_device(),\n              err,\n            );\n            return;\n          }\n        }\n      }\n    });\n    (client_sink, client_stream)\n  }\n\n  pub async fn send_message(&self, message: RealtimeMessage) {\n    self.sink.do_send(message);\n  }\n\n  #[inline]\n  async fn access_control(\n    workspace_id: &Uuid,\n    uid: &i64,\n    object_id: &Uuid,\n    access_control: Arc<dyn RealtimeAccessControl>,\n    messages: Vec<ClientCollabMessage>,\n  ) -> (Vec<ClientCollabMessage>, Vec<ClientCollabMessage>) {\n    let can_write = access_control\n      .can_write_collab(workspace_id, uid, object_id)\n      .await\n      .unwrap_or(false);\n\n    let mut valid_messages = Vec::with_capacity(messages.len());\n    let mut invalid_messages = Vec::with_capacity(messages.len());\n\n    for message in messages {\n      if can_write {\n        valid_messages.push(message);\n      } else {\n        invalid_messages.push(message);\n      }\n    }\n    (valid_messages, invalid_messages)\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/client/mod.rs",
    "content": "pub mod client_msg_router;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/cache/collab_cache.rs",
    "content": "use super::disk_cache::CollabDiskCache;\nuse super::mem_cache::{cache_exp_secs_from_collab_type, CollabMemCache, MillisSeconds};\nuse crate::collab::cache::DECODE_SPAWN_THRESHOLD;\nuse crate::config::get_env_var;\nuse crate::CollabMetrics;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_proto::{Rid, TimestampedEncodedCollab, UpdateFlags};\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse collab_stream::model::UpdateStreamMessage;\nuse dashmap::DashMap;\nuse database::file::s3_client_impl::AwsS3BucketClientImpl;\nuse database_entity::dto::{\n  CollabParams, CollabUpdateData, PendingCollabWrite, QueryCollab, QueryCollabResult,\n};\nuse futures_util::{stream, StreamExt};\nuse infra::thread_pool::ThreadPoolNoAbort;\nuse itertools::{Either, Itertools};\nuse rayon::prelude::*;\nuse sqlx::{PgPool, Transaction};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tracing::{debug, error, instrument, trace, warn};\nuse uuid::Uuid;\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::Encode;\nuse yrs::{ReadTxn, StateVector, Update};\n\npub struct CollabCache {\n  thread_pool: Arc<ThreadPoolNoAbort>,\n  disk_cache: CollabDiskCache,\n  mem_cache: CollabMemCache,\n  s3_collab_threshold: usize,\n  /// Threshold for spawning background tasks for memory cache operations.\n  /// Data smaller than this will be processed on the current thread.\n  /// Data larger than this will be spawned to avoid blocking.\n  small_collab_size: usize,\n  metrics: Arc<CollabMetrics>,\n  dirty_collabs: DashMap<Uuid, u64>,\n}\n\nimpl CollabCache {\n  pub fn new(\n    thread_pool: Arc<ThreadPoolNoAbort>,\n    redis_conn_manager: redis::aio::ConnectionManager,\n    pg_pool: PgPool,\n    s3: AwsS3BucketClientImpl,\n    metrics: Arc<CollabMetrics>,\n    s3_collab_threshold: usize,\n  ) -> Arc<Self> {\n    let mem_cache = CollabMemCache::new(\n      thread_pool.clone(),\n      redis_conn_manager.clone(),\n      metrics.clone(),\n    );\n    let disk_cache = CollabDiskCache::new(\n      thread_pool.clone(),\n      pg_pool.clone(),\n      s3,\n      s3_collab_threshold,\n      metrics.clone(),\n    );\n\n    let small_collab_size = get_env_var(\"APPFLOWY_SMALL_COLLAB_SIZE\", \"4096\")\n      .parse::<usize>()\n      .unwrap_or(DECODE_SPAWN_THRESHOLD);\n    Arc::new(Self {\n      thread_pool,\n      disk_cache,\n      mem_cache,\n      s3_collab_threshold,\n      small_collab_size,\n      metrics,\n      dirty_collabs: DashMap::new(),\n    })\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub fn mark_as_dirty(&self, object_id: Uuid, millis_secs: MillisSeconds) {\n    let millis_secs = millis_secs.into_inner();\n    match self.dirty_collabs.entry(object_id) {\n      dashmap::mapref::entry::Entry::Occupied(mut entry) => {\n        // Only update if the new timestamp is newer\n        if millis_secs > *entry.get() {\n          tracing::trace!(\n            \"marking collab {} as dirty at timestamp {}\",\n            object_id,\n            millis_secs\n          );\n          entry.insert(millis_secs);\n        }\n      },\n      dashmap::mapref::entry::Entry::Vacant(entry) => {\n        tracing::trace!(\n          \"marking collab {} as dirty at timestamp {}\",\n          object_id,\n          millis_secs\n        );\n        entry.insert(millis_secs);\n      },\n    }\n  }\n\n  pub fn is_dirty_since(&self, object_id: &Uuid, millis_seconds: MillisSeconds) -> bool {\n    let millis_secs = millis_seconds.into_inner();\n    if let Some(value) = self.dirty_collabs.get(object_id) {\n      let is_dirty = *value > millis_secs;\n      trace!(\n        \"collab {} is dirty:{}, dirty ts:{}, get ts:{}, \",\n        object_id,\n        is_dirty,\n        *value,\n        millis_secs,\n      );\n      is_dirty\n    } else {\n      // Mark the collab as dirty if it is not found in the cache. Any upcoming read with millis_secs\n      // less than the current timestamp will be considered dirty. When snapshot schedule write a new snapshot.\n      // The snapshot rid will bigger then stored time. then it will return false.\n      self.mark_as_dirty(*object_id, millis_secs.into());\n      true\n    }\n  }\n\n  pub fn metrics(&self) -> &CollabMetrics {\n    &self.metrics\n  }\n\n  /// **Note: This function will override any existing values without timestamp comparison.**\n  /// Use the single insert methods if you need conditional insertion based on timestamps.\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn bulk_insert_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params_list: Vec<CollabParams>,\n  ) -> Result<(), AppError> {\n    self\n      .disk_cache\n      .bulk_insert_collab(workspace_id, uid, params_list.clone())\n      .await?;\n\n    // Group params by collab type for different batch sizes\n    let mut database_rows = Vec::new();\n    let mut other_types = Vec::new();\n    for params in params_list {\n      let mills_secs = params\n        .updated_at\n        .as_ref()\n        .map(|v| MillisSeconds::from(v.timestamp_millis() as u64))\n        .unwrap_or_else(|| {\n          warn!(\n            \"CollabParams updated_at should not be None for object_id: {}\",\n            params.object_id\n          );\n          MillisSeconds::now()\n        });\n\n      let batch_item = (\n        params.object_id,\n        params.encoded_collab_v1,\n        mills_secs,\n        Some(cache_exp_secs_from_collab_type(&params.collab_type)),\n      );\n\n      match params.collab_type {\n        CollabType::DatabaseRow => database_rows.push(batch_item),\n        _ => other_types.push(batch_item),\n      }\n    }\n\n    let batch_row_size = get_env_var(\"APPFLOWY_REDIS_BATCH_DB_ROW_COLLAB_SIZE\", \"50\")\n      .parse::<usize>()\n      .unwrap_or(50);\n\n    // Process database rows in batches of 50\n    for batch in database_rows.chunks(batch_row_size) {\n      if let Err(err) = self\n        .mem_cache\n        .batch_insert_raw_data(batch)\n        .await\n        .map_err(|err| AppError::Internal(err.into()))\n      {\n        tracing::warn!(\n          \"Failed to batch insert {} database row collabs into memory cache: {}\",\n          batch.len(),\n          err\n        );\n      }\n    }\n\n    let size = get_env_var(\"APPFLOWY_REDIS_BATCH_COLLAB_SIZE\", \"10\")\n      .parse::<usize>()\n      .unwrap_or(10);\n    for batch in other_types.chunks(size) {\n      if let Err(err) = self\n        .mem_cache\n        .batch_insert_raw_data(batch)\n        .await\n        .map_err(|err| AppError::Internal(err.into()))\n      {\n        tracing::warn!(\n          \"Failed to batch insert {} collabs into memory cache: {}\",\n          batch.len(),\n          err\n        );\n      }\n    }\n\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn get_snapshot_collab(\n    &self,\n    workspace_id: &Uuid,\n    query: QueryCollab,\n  ) -> Result<(Rid, EncodedCollab), AppError> {\n    // Attempt to retrieve encoded collab from memory cache, falling back to disk cache if necessary.\n    if let Some((rid, encoded_collab)) = self.mem_cache.get_encode_collab(&query.object_id).await {\n      debug!(\n        \"Did get encode collab: {} from cache at {}\",\n        query.object_id, rid,\n      );\n      return Ok((rid, encoded_collab));\n    }\n\n    // Retrieve from disk cache as fallback. After retrieval, the value is inserted into the memory cache.\n    let object_id = query.object_id;\n    let expiration_secs = cache_exp_secs_from_collab_type(&query.collab_type);\n    let (rid, encode_collab) = self\n      .disk_cache\n      .get_encoded_collab_from_disk(workspace_id, query)\n      .await?;\n\n    self\n      .mem_cache\n      .insert_encode_collab(\n        &object_id,\n        encode_collab.clone(),\n        MillisSeconds::from(&rid),\n        expiration_secs,\n      )\n      .await;\n    Ok((rid, encode_collab))\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn get_full_collab(\n    &self,\n    workspace_id: &Uuid,\n    query: QueryCollab,\n    from: Option<StateVector>,\n    encoding: EncoderVersion,\n  ) -> Result<TimestampedEncodedCollab, AppError> {\n    let object_id = query.object_id;\n    let collab_type = query.collab_type;\n    let (rid, mut encoded_collab) = match self.get_snapshot_collab(workspace_id, query).await {\n      Ok((rid, encoded_collab)) => {\n        debug!(\n          \"Snapshot Collab:{} at {} found in cache\",\n          object_id, rid.timestamp\n        );\n        (rid, Some(encoded_collab))\n      },\n      Err(AppError::RecordNotFound(_)) => {\n        debug!(\n          \"Snapshot Collab:{} not found in cache, returning default state\",\n          object_id\n        );\n        (Rid::default(), None)\n      },\n      Err(err) => return Err(err),\n    };\n\n    let from = from.unwrap_or_default();\n    if !self.is_dirty_since(&object_id, MillisSeconds::from(&rid)) {\n      // there are no pending updates for this collab, so we can return the cached value directly\n      trace!(\"no pending updates for collab: {}\", object_id);\n      return match encoded_collab {\n        Some(encoded_collab) if encoded_collab.doc_state.len() <= self.small_collab_size => {\n          Ok(TimestampedEncodedCollab {\n            encoded_collab,\n            rid,\n          })\n        },\n        Some(encoded_collab) => {\n          // If the collab is large, we do not replay updates and return the snapshot only.\n          let diff_encoded = self.encode_diff_for_large_collab(object_id, encoded_collab, &from)?;\n          Ok(TimestampedEncodedCollab {\n            encoded_collab: diff_encoded,\n            rid,\n          })\n        },\n        None => Err(AppError::RecordNotFound(format!(\n          \"Collab not found for object_id: {}\",\n          object_id\n        ))),\n      };\n    }\n\n    let updates = self\n      .get_workspace_updates(workspace_id, Some(&object_id), Some(rid), None)\n      .await?;\n\n    let size = encoded_collab\n      .as_ref()\n      .map(|v| v.doc_state.len())\n      .unwrap_or(0)\n      + updates.iter().map(|u| u.update.len()).sum::<usize>();\n\n    trace!(\n      \"found {} pending updates for collab: {}/{}\",\n      updates.len(),\n      object_id,\n      collab_type\n    );\n    if !updates.is_empty() {\n      encoded_collab = if size <= self.small_collab_size {\n        // For small collab, replaying updates on the current thread\n        replaying_updates(\n          encoding,\n          object_id,\n          collab_type,\n          encoded_collab,\n          updates,\n          &from,\n        )?\n      } else {\n        self\n          .thread_pool\n          .install(|| {\n            replaying_updates(\n              encoding,\n              object_id,\n              collab_type,\n              encoded_collab,\n              updates,\n              &from,\n            )\n          })\n          .map_err(|err| AppError::Internal(err.into()))??\n      }\n    }\n\n    match encoded_collab {\n      Some(encoded_collab) => Ok(TimestampedEncodedCollab {\n        encoded_collab,\n        rid,\n      }),\n      None => Err(AppError::RecordNotFound(format!(\n        \"Collab not found for object_id: {}\",\n        object_id\n      ))),\n    }\n  }\n\n  pub async fn get_collabs_created_since(\n    &self,\n    workspace_id: Uuid,\n    since: DateTime<Utc>,\n    limit: usize,\n  ) -> Result<Vec<CollabUpdateData>, AppError> {\n    self\n      .disk_cache\n      .get_collabs_created_since(workspace_id, since, limit)\n      .await\n  }\n\n  pub async fn get_workspace_updates(\n    &self,\n    workspace_id: &Uuid,\n    object_id: Option<&Uuid>,\n    from: Option<Rid>,\n    to: Option<Rid>,\n  ) -> Result<Vec<UpdateStreamMessage>, AppError> {\n    self\n      .mem_cache\n      .get_workspace_updates(workspace_id, object_id, from, to)\n      .await\n  }\n\n  /// Batch get the encoded collab **SNAPSHOT** data from the cache.\n  /// This function only returns cached/stored data and does NOT apply pending updates.\n  /// Use batch_get_full_collab() if you need current state with updates applied.\n  /// Returns a hashmap of the object_id to the encoded collab data.\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn batch_get_snapshot_collab<T: Into<QueryCollab>>(\n    &self,\n    workspace_id: &Uuid,\n    queries: Vec<T>,\n  ) -> HashMap<Uuid, QueryCollabResult> {\n    let queries = queries.into_iter().map(Into::into).collect::<Vec<_>>();\n    let mut results = HashMap::new();\n\n    // Group queries by collab_type for optimal batch sizing\n    let mut db_row_queries = Vec::new();\n    let mut other_queries = Vec::new();\n\n    for query in queries {\n      if matches!(query.collab_type, CollabType::DatabaseRow) {\n        db_row_queries.push(query);\n      } else {\n        other_queries.push(query);\n      }\n    }\n\n    if !db_row_queries.is_empty() {\n      let size = get_env_var(\"APPFLOWY_REDIS_BATCH_DB_ROW_COLLAB_SIZE\", \"50\")\n        .parse::<usize>()\n        .unwrap_or(50);\n      for chunk in db_row_queries.chunks(size) {\n        let batch_results = self.process_query_batch(workspace_id, chunk.to_vec()).await;\n        results.extend(batch_results);\n      }\n    }\n\n    // Process other queries in batches of 20\n    if !other_queries.is_empty() {\n      let size = get_env_var(\"APPFLOWY_REDIS_BATCH_COLLAB_SIZE\", \"10\")\n        .parse::<usize>()\n        .unwrap_or(10);\n      for chunk in other_queries.chunks(size) {\n        let batch_results = self.process_query_batch(workspace_id, chunk.to_vec()).await;\n        results.extend(batch_results);\n      }\n    }\n\n    results\n  }\n\n  async fn process_query_batch(\n    &self,\n    workspace_id: &Uuid,\n    queries: Vec<QueryCollab>,\n  ) -> HashMap<Uuid, QueryCollabResult> {\n    let mut results = HashMap::new();\n    let object_ids: Vec<Uuid> = queries.iter().map(|q| q.object_id).collect();\n    let (disk_queries, values_from_mem_cache) =\n      match self.mem_cache.batch_get_data(&object_ids).await {\n        Ok(mem_cache_results) => {\n          let mem_cache_map: HashMap<Uuid, QueryCollabResult> = mem_cache_results\n            .into_iter()\n            .map(|(object_id, data)| {\n              (\n                object_id,\n                QueryCollabResult::Success {\n                  encode_collab_v1: data,\n                },\n              )\n            })\n            .collect();\n\n          let found_in_cache: std::collections::HashSet<Uuid> =\n            mem_cache_map.keys().copied().collect();\n          let disk_queries: Vec<QueryCollab> = queries\n            .into_iter()\n            .filter(|q| !found_in_cache.contains(&q.object_id))\n            .collect();\n\n          (disk_queries, mem_cache_map)\n        },\n        Err(err) => {\n          error!(\n            \"Batch get from memory cache failed: {}, falling back to individual calls\",\n            err\n          );\n          let (disk_queries, values_from_mem_cache): (Vec<_>, HashMap<_, _>) =\n            stream::iter(queries)\n              .then(|params| async move {\n                match self\n                  .mem_cache\n                  .get_data_with_timestamp(&params.object_id)\n                  .await\n                {\n                  Ok(Some((_ts, data))) => Either::Right((\n                    params.object_id,\n                    QueryCollabResult::Success {\n                      encode_collab_v1: data,\n                    },\n                  )),\n                  _ => Either::Left(params),\n                }\n              })\n              .collect::<Vec<_>>()\n              .await\n              .into_iter()\n              .partition_map(|either| either);\n          (disk_queries, values_from_mem_cache)\n        },\n      };\n\n    results.extend(values_from_mem_cache);\n\n    // 2. Retrieves remaining values from the disk cache for queries not satisfied by the memory cache.\n    let values_from_disk_cache = self\n      .disk_cache\n      .batch_get_collab(workspace_id, disk_queries)\n      .await;\n    results.extend(values_from_disk_cache);\n    results\n  }\n\n  /// Batch get the FULL/CURRENT collab data (applying pending updates for dirty collabs).\n  /// This function is consistent with get_full_collab - it applies pending updates.\n  /// Returns a hashmap of the object_id to the encoded collab data.\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn batch_get_full_collab<T: Into<QueryCollab>>(\n    &self,\n    workspace_id: &Uuid,\n    queries: Vec<T>,\n    from: Option<StateVector>,\n    encoding: EncoderVersion,\n  ) -> HashMap<Uuid, QueryCollabResult> {\n    let queries = queries.into_iter().map(Into::into).collect::<Vec<_>>();\n    let mut results = HashMap::new();\n\n    // For batch efficiency, separate dirty and clean collabs\n    let mut clean_queries = Vec::new();\n    let mut dirty_queries = Vec::new();\n\n    for query in queries {\n      if self.is_dirty_since(&query.object_id, MillisSeconds::now()) {\n        dirty_queries.push(query);\n      } else {\n        clean_queries.push(query);\n      }\n    }\n\n    debug!(\n      \"Batch processing collabs: {} clean, {} dirty\",\n      clean_queries.len(),\n      dirty_queries.len()\n    );\n\n    if !clean_queries.is_empty() {\n      let clean_results = self\n        .batch_get_snapshot_collab(workspace_id, clean_queries)\n        .await;\n      results.extend(clean_results);\n    }\n\n    let mut encoded_collab_by_object_id: HashMap<Uuid, EncodedCollab> =\n      HashMap::with_capacity(dirty_queries.len());\n    for query in dirty_queries {\n      let object_id = query.object_id;\n      match self\n        .get_full_collab(workspace_id, query, from.clone(), encoding.clone())\n        .await\n      {\n        Ok(value) => {\n          encoded_collab_by_object_id.insert(object_id, value.encoded_collab);\n        },\n        Err(err) => {\n          results.insert(\n            object_id,\n            QueryCollabResult::Failed {\n              error: err.to_string(),\n            },\n          );\n        },\n      }\n    }\n\n    if let Ok(entries) = self.thread_pool.install(|| {\n      encoded_collab_by_object_id\n        .into_par_iter()\n        .map(|(object_id, encoded_collab)| {\n          (\n            object_id,\n            QueryCollabResult::Success {\n              encode_collab_v1: encoded_collab.encode_to_bytes().unwrap(),\n            },\n          )\n        })\n        .collect::<HashMap<_, _>>()\n    }) {\n      results.extend(entries);\n    }\n    results\n  }\n\n  /// Insert the encoded collab data into the cache.\n  /// The data is inserted into both the memory and disk cache.\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn insert_encode_collab_data(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    params: CollabParams,\n    transaction: &mut Transaction<'_, sqlx::Postgres>,\n  ) -> Result<(), AppError> {\n    let collab_type = params.collab_type;\n    let object_id = params.object_id;\n    let encode_collab_data = params.encoded_collab_v1.clone();\n    let s3 = self.disk_cache.s3_client();\n    CollabDiskCache::upsert_collab_with_transaction(\n      workspace_id,\n      uid,\n      params,\n      transaction,\n      s3,\n      self.s3_collab_threshold,\n      &self.metrics,\n    )\n    .await?;\n\n    self\n      .cache_collab(\n        object_id,\n        collab_type,\n        encode_collab_data,\n        MillisSeconds::now(),\n      )\n      .await;\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn cache_collab(\n    &self,\n    object_id: Uuid,\n    collab_type: CollabType,\n    encode_collab_data: Bytes,\n    mills_secs: MillisSeconds,\n  ) {\n    let mem_cache = self.mem_cache.clone();\n    let expiration_secs = cache_exp_secs_from_collab_type(&collab_type);\n    if let Err(err) = mem_cache\n      .insert_encode_collab_data(\n        &object_id,\n        &encode_collab_data,\n        mills_secs,\n        Some(expiration_secs),\n      )\n      .await\n    {\n      error!(\"Failed to insert encode collab into memory cache: {}\", err);\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn insert_encode_collab_to_disk(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> Result<(), AppError> {\n    let mills_secs = params\n      .updated_at\n      .as_ref()\n      .map(|v| v.timestamp_millis() as u64)\n      .ok_or_else(|| AppError::Internal(anyhow!(\"Update_at should not be None\")))?;\n\n    let p = params.clone();\n    self\n      .disk_cache\n      .upsert_collab(workspace_id, uid, params)\n      .await?;\n\n    self\n      .cache_collab(\n        p.object_id,\n        p.collab_type,\n        p.encoded_collab_v1,\n        MillisSeconds::from(mills_secs),\n      )\n      .await;\n    Ok(())\n  }\n\n  pub async fn delete_collab(&self, workspace_id: &Uuid, object_id: &Uuid) -> Result<(), AppError> {\n    self.mem_cache.remove_encode_collab(object_id).await?;\n    self\n      .disk_cache\n      .delete_collab(workspace_id, object_id)\n      .await?;\n    Ok(())\n  }\n\n  pub async fn is_exist(&self, workspace_id: &Uuid, oid: &Uuid) -> Result<bool, AppError> {\n    if let Ok(value) = self.mem_cache.is_exist(oid).await {\n      if value {\n        return Ok(value);\n      }\n    }\n\n    let is_exist = self.disk_cache.is_exist(workspace_id, oid).await?;\n    Ok(is_exist)\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  pub async fn batch_insert_collab(\n    &self,\n    records: Vec<PendingCollabWrite>,\n  ) -> Result<(), AppError> {\n    let mem_cache_params: Vec<_> = records\n      .iter()\n      .map(|r| {\n        (\n          r.params.object_id,\n          r.params.encoded_collab_v1.clone(),\n          cache_exp_secs_from_collab_type(&r.params.collab_type),\n        )\n      })\n      .collect();\n\n    self.disk_cache.batch_insert_collab(records).await?;\n    for (oid, data, expire) in mem_cache_params {\n      if let Err(err) = self\n        .mem_cache\n        .insert_encode_collab_data(&oid, &data, MillisSeconds::now(), Some(expire))\n        .await\n      {\n        error!(\n          \"Failed to insert collab `{}` into memory cache: {}\",\n          oid, err\n        );\n      }\n    }\n\n    Ok(())\n  }\n\n  /// Encode diff for a large collab without full replay (optimization for clean large collabs)\n  fn encode_diff_for_large_collab(\n    &self,\n    object_id: Uuid,\n    encoded_collab: EncodedCollab,\n    from: &StateVector,\n  ) -> Result<EncodedCollab, AppError> {\n    let options = CollabOptions::new(object_id.to_string(), default_client_id()).with_data_source(\n      match encoded_collab.version {\n        EncoderVersion::V1 => DataSource::DocStateV1(encoded_collab.doc_state.into()),\n        EncoderVersion::V2 => DataSource::DocStateV2(encoded_collab.doc_state.into()),\n      },\n    );\n\n    let collab = Collab::new_with_options(CollabOrigin::Server, options)\n      .map_err(|err| AppError::Internal(err.into()))?;\n\n    let tx = collab.transact();\n    let doc_state: Bytes = match encoded_collab.version {\n      EncoderVersion::V1 => tx.encode_diff_v1(from),\n      EncoderVersion::V2 => tx.encode_diff_v2(from),\n    }\n    .into();\n\n    Ok(EncodedCollab {\n      version: encoded_collab.version,\n      state_vector: encoded_collab.state_vector,\n      doc_state,\n    })\n  }\n}\n\n#[inline]\nfn replaying_updates(\n  encoding: EncoderVersion,\n  object_id: Uuid,\n  collab_type: CollabType,\n  mut encoded_collab: Option<EncodedCollab>,\n  updates: Vec<UpdateStreamMessage>,\n  from: &StateVector,\n) -> Result<Option<EncodedCollab>, AppError> {\n  tracing::trace!(\n    \"replaying {} updates for {}/{}, from: {:#?}\",\n    updates.len(),\n    object_id,\n    collab_type,\n    from,\n  );\n  let mut collab = match encoded_collab {\n    Some(encoded_collab) => {\n      let options = CollabOptions::new(object_id.to_string(), default_client_id())\n        .with_data_source(match encoded_collab.version {\n          EncoderVersion::V1 => DataSource::DocStateV1(encoded_collab.doc_state.into()),\n          EncoderVersion::V2 => DataSource::DocStateV2(encoded_collab.doc_state.into()),\n        });\n      Collab::new_with_options(CollabOrigin::Server, options)\n        .map_err(|err| AppError::Internal(err.into()))?\n    },\n    None => {\n      let options = CollabOptions::new(object_id.to_string(), default_client_id());\n      Collab::new_with_options(CollabOrigin::Server, options)\n        .map_err(|err| AppError::Internal(err.into()))?\n    },\n  };\n  {\n    let mut tx = collab.transact_mut();\n    for msg in updates {\n      if msg.object_id == object_id {\n        let update = match msg.update_flags {\n          UpdateFlags::Lib0v1 => Update::decode_v1(&msg.update),\n          UpdateFlags::Lib0v2 => Update::decode_v2(&msg.update),\n        }\n        .map_err(|err| AppError::DecodeUpdateError(err.to_string()))?;\n\n        tracing::trace!(\n          \"replaying {} update for {}/{}: {:#?}\",\n          msg.last_message_id,\n          object_id,\n          collab_type,\n          update,\n        );\n        tx.apply_update(update)\n          .map_err(|err| AppError::ApplyUpdateError(err.to_string()))?;\n      }\n    }\n  }\n  let tx = collab.transact();\n  encoded_collab = Some(match encoding {\n    EncoderVersion::V1 => {\n      EncodedCollab::new_v1(tx.state_vector().encode_v1(), tx.encode_diff_v1(from))\n    },\n    EncoderVersion::V2 => {\n      EncodedCollab::new_v2(tx.state_vector().encode_v2(), tx.encode_diff_v2(from))\n    },\n  });\n\n  Ok(encoded_collab)\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/cache/disk_cache.rs",
    "content": "use crate::collab::cache::encode_collab_from_bytes_with_thread_pool;\nuse crate::CollabMetrics;\nuse anyhow::{anyhow, Context};\nuse app_error::AppError;\nuse appflowy_proto::Rid;\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse collab_entity::CollabType;\nuse database::collab::{\n  batch_select_collab_blob, insert_into_af_collab, insert_into_af_collab_bulk_for_user,\n  is_collab_exists, select_blob_from_af_collab, select_collabs_created_since, AppResult,\n};\nuse database::file::s3_client_impl::AwsS3BucketClientImpl;\nuse database::file::{BucketClient, ResponseBlob};\nuse database_entity::dto::{\n  CollabParams, CollabUpdateData, PendingCollabWrite, QueryCollab, QueryCollabResult,\n  ZSTD_COMPRESSION_LEVEL,\n};\nuse infra::thread_pool::ThreadPoolNoAbort;\nuse sqlx::{Error, PgPool, Transaction};\nuse std::collections::HashMap;\nuse std::ops::DerefMut;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::task::JoinSet;\nuse tokio::time::sleep;\nuse tracing::{debug, error, instrument, trace};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub struct CollabDiskCache {\n  thread_pool: Arc<ThreadPoolNoAbort>,\n  pg_pool: PgPool,\n  s3: AwsS3BucketClientImpl,\n  s3_collab_threshold: usize,\n  metrics: Arc<CollabMetrics>,\n}\n\nimpl CollabDiskCache {\n  pub fn new(\n    thread_pool: Arc<ThreadPoolNoAbort>,\n    pg_pool: PgPool,\n    s3: AwsS3BucketClientImpl,\n    s3_collab_threshold: usize,\n    metrics: Arc<CollabMetrics>,\n  ) -> Self {\n    Self {\n      thread_pool,\n      pg_pool,\n      s3,\n      s3_collab_threshold,\n      metrics,\n    }\n  }\n\n  pub async fn is_exist(&self, workspace_id: &Uuid, object_id: &Uuid) -> AppResult<bool> {\n    let dir = collab_key_prefix(workspace_id, object_id);\n    let resp = self.s3.list_dir(&dir, 1).await?;\n    if resp.is_empty() {\n      // fallback to Postgres\n      Ok(is_collab_exists(object_id, &self.pg_pool).await?)\n    } else {\n      Ok(true)\n    }\n  }\n\n  pub async fn upsert_collab(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> AppResult<()> {\n    // Start a database transaction\n    let mut transaction = self\n      .pg_pool\n      .begin()\n      .await\n      .context(\"Failed to acquire transaction for writing pending collaboration data\")\n      .map_err(AppError::from)?;\n\n    let start = Instant::now();\n    Self::upsert_collab_with_transaction(\n      workspace_id,\n      uid,\n      params,\n      &mut transaction,\n      self.s3.clone(),\n      self.s3_collab_threshold,\n      &self.metrics,\n    )\n    .await?;\n\n    tokio::time::timeout(Duration::from_secs(10), transaction.commit())\n      .await\n      .map_err(|_| {\n        AppError::Internal(anyhow!(\n          \"Timeout when committing the transaction for pending collaboration data\"\n        ))\n      })??;\n    self.metrics.observe_pg_tx(start.elapsed());\n\n    Ok(())\n  }\n\n  pub fn s3_client(&self) -> AwsS3BucketClientImpl {\n    self.s3.clone()\n  }\n\n  pub async fn upsert_collab_with_transaction(\n    workspace_id: &Uuid,\n    uid: &i64,\n    mut params: CollabParams,\n    transaction: &mut Transaction<'_, sqlx::Postgres>,\n    s3: AwsS3BucketClientImpl,\n    s3_collab_threshold: usize,\n    metrics: &CollabMetrics,\n  ) -> AppResult<()> {\n    let mut delete_from_s3 = Vec::new();\n    let key = collab_key(workspace_id, &params.object_id);\n    if params.encoded_collab_v1.len() > s3_collab_threshold {\n      // put collab into S3\n      let encoded_collab = std::mem::take(&mut params.encoded_collab_v1);\n      tokio::spawn(Self::insert_blob_with_retries(\n        s3.clone(),\n        key,\n        encoded_collab,\n        3,\n      ));\n      metrics.s3_write_collab_count.inc();\n    } else {\n      // put collab into Postgres (and remove outdated version from S3)\n      metrics.pg_write_collab_count.inc();\n      delete_from_s3.push(key);\n    }\n\n    insert_into_af_collab(transaction, uid, workspace_id, &params).await?;\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn get_collabs_created_since(\n    &self,\n    workspace_id: Uuid,\n    since: DateTime<Utc>,\n    limit: usize,\n  ) -> Result<Vec<CollabUpdateData>, AppError> {\n    let mut collabs: HashMap<_, _> =\n      select_collabs_created_since(&self.pg_pool, &workspace_id, since, limit)\n        .await?\n        .into_iter()\n        .flat_map(|record| {\n          let encoded_collab = if record.blob.is_empty() {\n            EncodedCollab {\n              state_vector: Default::default(),\n              doc_state: Default::default(),\n              version: Default::default(),\n            }\n          } else {\n            EncodedCollab::decode_from_bytes(&record.blob).ok()?\n          };\n          Some((\n            record.oid,\n            CollabUpdateData {\n              object_id: record.oid,\n              collab_type: CollabType::from(record.partition_key),\n              encoded_collab,\n              updated_at: Some(record.updated_at),\n            },\n          ))\n        })\n        .collect();\n    tracing::debug!(\n      \"Found {} collabs created in workspace {} since {}\",\n      collabs.len(),\n      workspace_id,\n      since\n    );\n    let mut join_set = JoinSet::new();\n    for (&oid, collab) in collabs.iter() {\n      if collab.encoded_collab.doc_state.is_empty() {\n        let s3 = self.s3.clone();\n        self.metrics.s3_read_collab_count.inc();\n        join_set.spawn(async move {\n          let key = collab_key(&workspace_id, &oid);\n          (oid, Self::get_collab_from_s3(&s3, key).await)\n        });\n      }\n    }\n    while let Some(Ok((oid, res))) = join_set.join_next().await {\n      match res {\n        Ok((rid, encoded_collab)) => {\n          if let Some(collab) = collabs.get_mut(&oid) {\n            // Double-check that the record hasn't been deleted since the initial query\n            match self.is_collab_deleted(&oid).await {\n              Ok(true) => {\n                // Record was deleted, remove it from results\n                tracing::warn!(\n                  \"Collab {} was deleted after initial query, removing from results\",\n                  oid\n                );\n                collabs.remove(&oid);\n              },\n              Ok(false) => {\n                // Record is still valid, update with S3 data\n                collab.updated_at = DateTime::from_timestamp_millis(rid.timestamp as i64);\n                collab.encoded_collab = encoded_collab;\n              },\n              Err(err) => {\n                // Error checking deletion status, remove from results to be safe\n                tracing::warn!(\n                  \"Error checking deletion status for collab {}: {}, removing from results\",\n                  oid,\n                  err\n                );\n                collabs.remove(&oid);\n              },\n            }\n          }\n        },\n        Err(err) => {\n          tracing::warn!(\"failed to get collab {} state from S3: {}\", oid, err);\n          collabs.remove(&oid);\n        },\n      }\n    }\n    Ok(collabs.into_values().collect())\n  }\n\n  async fn get_collab_from_s3(\n    s3: &AwsS3BucketClientImpl,\n    key: String,\n  ) -> Result<(Rid, EncodedCollab), AppError> {\n    match s3.get_blob(&key).await {\n      Ok(resp) => {\n        let blob = resp.to_blob();\n        let now = Instant::now();\n        let decompressed = zstd::decode_all(&*blob)?;\n        tracing::trace!(\n          \"decompressed collab {}B -> {}B in {:?}\",\n          blob.len(),\n          decompressed.len(),\n          now.elapsed()\n        );\n        let encoded_collab = EncodedCollab {\n          state_vector: Default::default(),\n          doc_state: decompressed.into(),\n          version: EncoderVersion::V1,\n        };\n        let rid = Rid::default(); //TODO: we need to store it somewhere\n        Ok((rid, encoded_collab))\n      },\n      Err(err) => Err(err),\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn get_encoded_collab_from_disk(\n    &self,\n    workspace_id: &Uuid,\n    query: QueryCollab,\n  ) -> Result<(Rid, EncodedCollab), AppError> {\n    debug!(\"try get {}:{} from s3\", query.collab_type, query.object_id);\n    let key = collab_key(workspace_id, &query.object_id);\n\n    let is_deleted = self.is_collab_deleted(&query.object_id).await?;\n    if is_deleted {\n      return Err(AppError::RecordDeleted(format!(\n        \"{}/{} is deleted from db\",\n        query.collab_type, query.object_id\n      )));\n    }\n\n    match Self::get_collab_from_s3(&self.s3, key).await {\n      Ok((rid, encoded_collab)) => {\n        self.metrics.s3_read_collab_count.inc();\n        return Ok((rid, encoded_collab));\n      },\n      Err(AppError::RecordNotFound(_)) => {\n        debug!(\n          \"Can not find the {}/{} from s3, trying to get from Postgres\",\n          query.collab_type, query.object_id\n        );\n      },\n      Err(e) => return Err(e),\n    }\n\n    const MAX_ATTEMPTS: usize = 3;\n    let mut attempts = 0;\n\n    loop {\n      let result =\n        select_blob_from_af_collab(&self.pg_pool, &query.collab_type, &query.object_id).await;\n\n      match result {\n        Ok((updated_at, data)) => {\n          self.metrics.pg_read_collab_count.inc();\n          let rid = Rid::new(updated_at.timestamp_millis() as u64, 0);\n          let encoded_collab =\n            encode_collab_from_bytes_with_thread_pool(&self.thread_pool, data).await?;\n          return Ok((rid, encoded_collab));\n        },\n        Err(e) => {\n          match e {\n            Error::RowNotFound => {\n              debug!(\n                \"Can not find the {}/{} from Postgres\",\n                query.object_id, query.collab_type\n              );\n              let msg = format!(\"Can't find the row for query: {:?}\", query);\n              return Err(AppError::RecordNotFound(msg));\n            },\n            _ => {\n              // Increment attempts and retry if below MAX_ATTEMPTS and the error is retryable\n              if attempts < MAX_ATTEMPTS - 1 && matches!(e, sqlx::Error::PoolTimedOut) {\n                attempts += 1;\n                sleep(Duration::from_millis(500 * attempts as u64)).await;\n                continue;\n              } else {\n                return Err(e.into());\n              }\n            },\n          }\n        },\n      }\n    }\n  }\n\n  //FIXME: this and `batch_insert_collab` duplicate similar logic.\n  pub async fn bulk_insert_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    mut params_list: Vec<CollabParams>,\n  ) -> Result<(), AppError> {\n    if params_list.is_empty() {\n      return Ok(());\n    }\n\n    let mut delete_from_s3 = Vec::new();\n    let mut blobs = HashMap::new();\n    for param in params_list.iter_mut() {\n      let key = collab_key(&workspace_id, &param.object_id);\n      if param.encoded_collab_v1.len() > self.s3_collab_threshold {\n        let blob = std::mem::take(&mut param.encoded_collab_v1);\n        blobs.insert(key, blob);\n      } else {\n        // put collab into Postgres (and remove outdated version from S3)\n        delete_from_s3.push(key);\n      }\n    }\n    let s3_count = blobs.len() as u64;\n    let pg_count = delete_from_s3.len() as u64;\n\n    let mut transaction = self.pg_pool.begin().await?;\n    let start = Instant::now();\n    insert_into_af_collab_bulk_for_user(&mut transaction, uid, workspace_id, &params_list).await?;\n    transaction.commit().await?;\n    self.metrics.observe_pg_tx(start.elapsed());\n\n    batch_put_collab_to_s3(&self.s3, blobs).await?;\n    if !delete_from_s3.is_empty() {\n      let s3 = self.s3.clone();\n      tokio::spawn(async move {\n        if let Err(err) = s3.delete_blobs(delete_from_s3).await {\n          tracing::warn!(\"failed to delete outdated collabs from S3: {}\", err);\n        }\n      });\n    }\n    self.metrics.s3_write_collab_count.inc_by(s3_count);\n    self.metrics.pg_write_collab_count.inc_by(pg_count);\n    Ok(())\n  }\n\n  pub async fn batch_insert_collab(\n    &self,\n    records: Vec<PendingCollabWrite>,\n  ) -> Result<(), AppError> {\n    if records.is_empty() {\n      return Ok(());\n    }\n\n    let s3 = self.s3.clone();\n    // Start a database transaction\n    let mut transaction = self\n      .pg_pool\n      .begin()\n      .await\n      .context(\"Failed to acquire transaction for writing pending collaboration data\")\n      .map_err(AppError::from)?;\n    let start = Instant::now();\n\n    // Insert each record into the database within the transaction context\n    let mut action_description = String::new();\n    for (index, record) in records.into_iter().enumerate() {\n      let params = record.params;\n      action_description = format!(\"{}\", params);\n      let savepoint_name = format!(\"sp_{}\", index);\n\n      // using savepoint to rollback the transaction if the insert fails\n      sqlx::query(&format!(\"SAVEPOINT {}\", savepoint_name))\n        .execute(transaction.deref_mut())\n        .await?;\n      if let Err(_err) = Self::upsert_collab_with_transaction(\n        &record.workspace_id,\n        &record.uid,\n        params,\n        &mut transaction,\n        s3.clone(),\n        self.s3_collab_threshold,\n        &self.metrics,\n      )\n      .await\n      {\n        sqlx::query(&format!(\"ROLLBACK TO SAVEPOINT {}\", savepoint_name))\n          .execute(transaction.deref_mut())\n          .await?;\n      }\n    }\n\n    // Commit the transaction to finalize all writes\n    match tokio::time::timeout(Duration::from_secs(10), transaction.commit()).await {\n      Ok(result) => {\n        self.metrics.observe_pg_tx(start.elapsed());\n        result.map_err(AppError::from)?;\n      },\n      Err(_) => {\n        error!(\n          \"Timeout waiting for committing the transaction for pending write:{}\",\n          action_description\n        );\n        return Err(AppError::Internal(anyhow!(\n          \"Timeout when committing the transaction for pending collaboration data\"\n        )));\n      },\n    }\n    Ok(())\n  }\n\n  /// Batch retrieves collaboration data for multiple queries.\n  ///\n  /// This method first checks if any of the requested collaborations have been deleted\n  /// and filters them out before attempting to retrieve data from S3 or PostgreSQL.\n  /// This is important because when a collaboration is deleted, it's only marked as\n  /// deleted in PostgreSQL (deleted_at field), but the S3 blob might still exist.\n  pub async fn batch_get_collab(\n    &self,\n    workspace_id: &Uuid,\n    queries: Vec<QueryCollab>,\n  ) -> HashMap<Uuid, QueryCollabResult> {\n    // Filter out deleted collabs before processing\n    let mut valid_queries = Vec::new();\n    let mut results = HashMap::new();\n\n    // Batch check deletion status for all queries\n    let object_ids: Vec<Uuid> = queries.iter().map(|q| q.object_id).collect();\n    let deletion_status = match self.batch_is_collab_deleted(&object_ids).await {\n      Ok(status) => status,\n      Err(err) => {\n        // If batch check fails, mark all queries as failed\n        for query in queries {\n          results.insert(\n            query.object_id,\n            QueryCollabResult::Failed {\n              error: format!(\"Error checking deletion status: {}\", err),\n            },\n          );\n        }\n        return results;\n      },\n    };\n\n    for query in queries {\n      let is_deleted = deletion_status.get(&query.object_id).unwrap_or(&false);\n      if *is_deleted {\n        // Record is deleted, add error result\n        results.insert(\n          query.object_id,\n          QueryCollabResult::Failed {\n            error: format!(\n              \"Collaboration record for {}:{} is deleted\",\n              query.collab_type, query.object_id\n            ),\n          },\n        );\n      } else {\n        // Record is not deleted, add to valid queries\n        valid_queries.push(query);\n      }\n    }\n\n    let not_found =\n      batch_get_collab_from_s3(&self.s3, workspace_id, valid_queries, &mut results).await;\n    let s3_fetch = results.len() as u64;\n    batch_select_collab_blob(&self.pg_pool, not_found, &mut results).await;\n    let pg_fetch = results.len() as u64 - s3_fetch;\n    self.metrics.s3_read_collab_count.inc_by(s3_fetch);\n    self.metrics.pg_read_collab_count.inc_by(pg_fetch);\n    results\n  }\n\n  pub async fn delete_collab(&self, workspace_id: &Uuid, object_id: &Uuid) -> AppResult<()> {\n    sqlx::query!(\n      r#\"\n        UPDATE af_collab\n        SET deleted_at = $2\n        WHERE oid = $1;\n        \"#,\n      object_id,\n      chrono::Utc::now()\n    )\n    .execute(&self.pg_pool)\n    .await?;\n\n    trace!(\"record {}:{} marked as deleted\", workspace_id, object_id);\n    let key = collab_key(workspace_id, object_id);\n    match self.s3.delete_blob(&key).await {\n      Ok(_) | Err(AppError::RecordNotFound(_)) => Ok(()),\n      Err(err) => Err(err),\n    }\n  }\n\n  pub async fn is_collab_deleted(&self, object_id: &Uuid) -> AppResult<bool> {\n    let result = sqlx::query!(\n      r#\"\n        SELECT deleted_at IS NOT NULL AS is_deleted\n        FROM af_collab\n        WHERE oid = $1;\n      \"#,\n      object_id\n    )\n    .fetch_one(&self.pg_pool)\n    .await?;\n\n    Ok(result.is_deleted.unwrap_or(false))\n  }\n\n  pub async fn batch_is_collab_deleted(\n    &self,\n    object_ids: &[Uuid],\n  ) -> AppResult<HashMap<Uuid, bool>> {\n    if object_ids.is_empty() {\n      return Ok(HashMap::new());\n    }\n\n    let results = sqlx::query!(\n      r#\"\n        SELECT oid, deleted_at IS NOT NULL AS is_deleted\n        FROM af_collab\n        WHERE oid = ANY($1);\n      \"#,\n      object_ids\n    )\n    .fetch_all(&self.pg_pool)\n    .await?;\n\n    let mut deletion_status = HashMap::new();\n    for result in results {\n      deletion_status.insert(result.oid, result.is_deleted.unwrap_or(false));\n    }\n\n    // For object_ids not found in the database, consider them as not deleted (they might not exist yet)\n    for &object_id in object_ids {\n      deletion_status.entry(object_id).or_insert(false);\n    }\n\n    Ok(deletion_status)\n  }\n\n  async fn insert_blob_with_retries(\n    s3: AwsS3BucketClientImpl,\n    key: String,\n    blob: Bytes,\n    mut retries: usize,\n  ) -> Result<(), AppError> {\n    let doc_state = Self::compress_encoded_collab(blob)?;\n    while let Err(err) = s3.put_blob(&key, doc_state.clone().into(), None).await {\n      match err {\n        AppError::ServiceTemporaryUnavailable(err) if retries > 0 => {\n          tracing::debug!(\n            \"S3 service is temporarily unavailable: {}. Remaining retries: {}\",\n            err,\n            retries\n          );\n          retries -= 1;\n          sleep(Duration::from_secs(5)).await;\n        },\n        err => {\n          tracing::error!(\"Failed to save collab to S3: {}\", err);\n          break;\n        },\n      }\n    }\n    tracing::trace!(\"saved collab to S3: {}\", key);\n    Ok(())\n  }\n\n  fn compress_encoded_collab(encoded_collab_v1: Bytes) -> Result<Bytes, AppError> {\n    let encoded_collab = EncodedCollab::decode_from_bytes(&encoded_collab_v1)\n      .map_err(|err| AppError::Internal(err.into()))?;\n    let now = Instant::now();\n    let doc_state = zstd::encode_all(&*encoded_collab.doc_state, ZSTD_COMPRESSION_LEVEL)?;\n    tracing::trace!(\n      \"compressed collab {}B -> {}B in {:?}\",\n      encoded_collab_v1.len(),\n      doc_state.len(),\n      now.elapsed()\n    );\n    Ok(doc_state.into())\n  }\n}\n\nasync fn batch_put_collab_to_s3(\n  s3: &AwsS3BucketClientImpl,\n  collabs: HashMap<String, Bytes>,\n) -> Result<(), AppError> {\n  let mut join_set = JoinSet::<Result<(), AppError>>::new();\n  let mut i = 0;\n  for (key, blob) in collabs {\n    let s3 = s3.clone();\n    join_set.spawn(async move {\n      let compressed = CollabDiskCache::compress_encoded_collab(blob)?;\n      s3.put_blob(&key, compressed.into(), None).await?;\n      Ok(())\n    });\n    i += 1;\n    if i % 500 == 0 {\n      while let Some(result) = join_set.join_next().await {\n        result.map_err(|err| AppError::Internal(err.into()))??;\n      }\n    }\n  }\n\n  while let Some(result) = join_set.join_next().await {\n    result.map_err(|err| AppError::Internal(err.into()))??;\n  }\n\n  Ok(())\n}\n\nasync fn batch_get_collab_from_s3(\n  s3: &AwsS3BucketClientImpl,\n  workspace_id: &Uuid,\n  params: Vec<QueryCollab>,\n  results: &mut HashMap<Uuid, QueryCollabResult>,\n) -> Vec<QueryCollab> {\n  enum GetResult {\n    Found(Uuid, Vec<u8>),\n    NotFound(QueryCollab),\n    Error(Uuid, String),\n  }\n\n  async fn gather(\n    join_set: &mut JoinSet<GetResult>,\n    results: &mut HashMap<Uuid, QueryCollabResult>,\n    not_found: &mut Vec<QueryCollab>,\n  ) {\n    while let Some(result) = join_set.join_next().await {\n      let now = Instant::now();\n      match result {\n        Ok(GetResult::Found(object_id, compressed)) => match zstd::decode_all(&*compressed) {\n          Ok(decompressed) => {\n            tracing::trace!(\n              \"decompressed collab {}B -> {}B in {:?}\",\n              compressed.len(),\n              decompressed.len(),\n              now.elapsed()\n            );\n            let encoded_collab = EncodedCollab {\n              state_vector: Default::default(),\n              doc_state: decompressed.into(),\n              version: EncoderVersion::V1,\n            };\n            results.insert(\n              object_id,\n              QueryCollabResult::Success {\n                encode_collab_v1: encoded_collab.encode_to_bytes().unwrap(),\n              },\n            );\n          },\n          Err(err) => {\n            results.insert(\n              object_id,\n              QueryCollabResult::Failed {\n                error: err.to_string(),\n              },\n            );\n          },\n        },\n        Ok(GetResult::NotFound(query)) => not_found.push(query),\n        Ok(GetResult::Error(object_id, error)) => {\n          results.insert(object_id, QueryCollabResult::Failed { error });\n        },\n        Err(err) => error!(\"Failed to get collab from S3: {}\", err),\n      }\n    }\n  }\n\n  let mut not_found = Vec::new();\n  let mut i = 0;\n  let mut join_set = JoinSet::new();\n  for query in params {\n    let key = collab_key(workspace_id, &query.object_id);\n    let s3 = s3.clone();\n    join_set.spawn(async move {\n      match s3.get_blob(&key).await {\n        Ok(resp) => GetResult::Found(query.object_id, resp.to_blob()),\n        Err(AppError::RecordNotFound(_)) => GetResult::NotFound(query),\n        Err(err) => GetResult::Error(query.object_id, err.to_string()),\n      }\n    });\n    i += 1;\n    if i % 500 == 0 {\n      gather(&mut join_set, results, &mut not_found).await;\n    }\n  }\n  // gather remaining results from the last chunk\n  gather(&mut join_set, results, &mut not_found).await;\n  not_found\n}\n\nfn collab_key_prefix(workspace_id: &Uuid, object_id: &Uuid) -> String {\n  format!(\"collabs/{}/{}/\", workspace_id, object_id)\n}\n\nfn collab_key(workspace_id: &Uuid, object_id: &Uuid) -> String {\n  format!(\n    \"collabs/{}/{}/encoded_collab.v1.zstd\",\n    workspace_id, object_id\n  )\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/cache/mem_cache.rs",
    "content": "use crate::collab::cache::{\n  encode_collab_from_bytes, encode_collab_from_bytes_with_thread_pool, DECODE_SPAWN_THRESHOLD,\n};\nuse crate::config::get_env_var;\nuse crate::CollabMetrics;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_proto::Rid;\nuse bytes::Bytes;\nuse collab::entity::EncodedCollab;\nuse collab_entity::CollabType;\nuse collab_stream::model::UpdateStreamMessage;\nuse collab_stream::stream_router::FromRedisStream;\nuse infra::thread_pool::ThreadPoolNoAbort;\nuse rayon::prelude::*;\nuse redis::streams::StreamRangeReply;\nuse redis::{pipe, AsyncCommands, FromRedisValue};\nuse std::fmt::Display;\nuse std::sync::Arc;\nuse tracing::{error, instrument, trace};\nuse uuid::Uuid;\n\nconst SEVEN_DAYS: u64 = 604800;\nconst ONE_MONTH: u64 = 2592000;\n\n/// Threshold for spawning blocking tasks for encoding operations.\n/// Data smaller than this will be processed on the current thread for efficiency.\n/// Data larger than this will be spawned to avoid blocking the current thread.\nconst ENCODE_SPAWN_THRESHOLD: usize = 4096; // 4KB\n\n#[derive(Debug, Clone, Copy)]\npub struct MillisSeconds(pub u64);\n\nimpl Display for MillisSeconds {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(f, \"{}\", self.0)\n  }\n}\n\nimpl From<&Rid> for MillisSeconds {\n  fn from(rid: &Rid) -> Self {\n    Self(rid.timestamp)\n  }\n}\n\nimpl From<u64> for MillisSeconds {\n  fn from(value: u64) -> Self {\n    Self(value)\n  }\n}\n\nimpl MillisSeconds {\n  pub fn now() -> Self {\n    Self(chrono::Utc::now().timestamp_millis() as u64)\n  }\n\n  pub fn into_inner(self) -> u64 {\n    self.0\n  }\n}\n#[derive(Clone)]\npub struct CollabMemCache {\n  thread_pool: Arc<ThreadPoolNoAbort>,\n  connection_manager: redis::aio::ConnectionManager,\n  metrics: Arc<CollabMetrics>,\n  /// Threshold for spawning background tasks for memory cache operations.\n  /// Data smaller than this will be processed on the current thread.\n  /// Data larger than this will be spawned to avoid blocking.\n  small_collab_size: usize,\n}\n\nimpl CollabMemCache {\n  pub fn new(\n    thread_pool: Arc<ThreadPoolNoAbort>,\n    connection_manager: redis::aio::ConnectionManager,\n    metrics: Arc<CollabMetrics>,\n  ) -> Self {\n    let small_collab_size = get_env_var(\"APPFLOWY_SMALL_COLLAB_SIZE\", \"4096\")\n      .parse::<usize>()\n      .unwrap_or(DECODE_SPAWN_THRESHOLD);\n    Self {\n      thread_pool,\n      connection_manager,\n      metrics,\n      small_collab_size,\n    }\n  }\n\n  /// Checks if an object with the given ID exists in the cache.\n  pub async fn is_exist(&self, object_id: &Uuid) -> Result<bool, AppError> {\n    let cache_object_id = encode_collab_key(object_id);\n    let exists: bool = self\n      .connection_manager\n      .clone()\n      .exists(&cache_object_id)\n      .await\n      .map_err(|err| AppError::Internal(err.into()))?;\n    Ok(exists)\n  }\n\n  pub async fn remove_encode_collab(&self, object_id: &Uuid) -> Result<(), AppError> {\n    trace!(\"Removing encode collab from cache: {}\", object_id);\n    let cache_object_id = encode_collab_key(object_id);\n    self\n      .connection_manager\n      .clone()\n      .del::<&str, ()>(&cache_object_id)\n      .await\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n          \"Failed to remove encoded collab from redis: {}\",\n          err\n        ))\n      })\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn get_encode_collab(&self, object_id: &Uuid) -> Option<(Rid, EncodedCollab)> {\n    match self.get_data_with_timestamp(object_id).await {\n      Ok(Some((timestamp, bytes))) => {\n        let encoded_collab = encode_collab_from_bytes_with_thread_pool(&self.thread_pool, bytes)\n          .await\n          .ok()?;\n        let rid = Rid::new(timestamp, 0);\n        Some((rid, encoded_collab))\n      },\n      Ok(None) => None,\n      Err(err) => {\n        error!(\"Failed to get encoded collab from redis: {}\", err);\n        None\n      },\n    }\n  }\n\n  pub async fn batch_get_data(\n    &self,\n    object_ids: &[Uuid],\n  ) -> Result<Vec<(Uuid, Vec<u8>)>, AppError> {\n    if object_ids.is_empty() {\n      return Ok(Vec::new());\n    }\n\n    trace!(\"Batch getting {} raw data\", object_ids.len());\n    let cache_keys: Vec<String> = object_ids.iter().map(encode_collab_key).collect();\n    let mut conn = self.connection_manager.clone();\n    let mut pipeline = pipe();\n    for key in &cache_keys {\n      pipeline.get(key);\n    }\n\n    let raw_results = pipeline\n      .query_async::<Vec<Option<Vec<u8>>>>(&mut conn)\n      .await\n      .map_err(|err| AppError::Internal(anyhow!(\"Failed to batch get from Redis: {}\", err)))?;\n\n    let results: Vec<(Uuid, Vec<u8>)> = self\n      .thread_pool\n      .install(|| {\n        raw_results\n          .into_par_iter()\n          .enumerate()\n          .filter_map(|(i, raw_data)| {\n            raw_data.and_then(|data| {\n              let object_id = object_ids[i];\n              self\n                .extract_timestamp_and_payload_with_metrics(&data, Some(&object_id))\n                .map(|(_, payload)| (object_id, payload))\n            })\n          })\n          .collect()\n      })\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n          \"Thread pool panic during batch processing: {}\",\n          err\n        ))\n      })?;\n\n    Ok(results)\n  }\n\n  /// Batch retrieves multiple encoded collaborations efficiently using Redis pipeline.\n  ///\n  /// # Arguments\n  /// * `object_ids` - A slice of object IDs to retrieve\n  ///\n  /// # Returns\n  /// A vector of results in the same order as input, where each result is:\n  /// - `Some((Rid, EncodedCollab))` if found and successfully decoded\n  /// - `None` if not found or decoding failed\n  ///\n  /// # Performance\n  /// Uses Redis pipelining to minimize network roundtrips and processes\n  /// decoding tasks efficiently based on data size.\n  #[instrument(level = \"trace\", skip_all, fields(count = object_ids.len()))]\n  pub async fn batch_get_encode_collab(&self, object_ids: &[Uuid]) -> Vec<(Rid, EncodedCollab)> {\n    if object_ids.is_empty() {\n      return Vec::new();\n    }\n\n    trace!(\"Batch getting {} encoded collabs\", object_ids.len());\n    // Build cache keys\n    let cache_keys: Vec<String> = object_ids.iter().map(encode_collab_key).collect();\n    let mut conn = self.connection_manager.clone();\n    let mut pipeline = pipe();\n    for key in &cache_keys {\n      pipeline.get(key);\n    }\n\n    // Execute pipeline\n    let raw_results = match pipeline\n      .query_async::<Vec<Option<Vec<u8>>>>(&mut conn)\n      .await\n    {\n      Ok(results) => results,\n      Err(err) => {\n        error!(\"Failed to batch get from Redis: {}\", err);\n        return vec![];\n      },\n    };\n\n    let final_results = self\n      .thread_pool\n      .install(|| {\n        raw_results\n          .into_par_iter()\n          .enumerate()\n          .filter_map(|(i, raw_data)| {\n            let data = raw_data?;\n            let object_id = object_ids.get(i);\n            let (timestamp, encoded_collab) = self.extract_and_decode_collab(&data, object_id)?;\n            let rid = Rid::new(timestamp, 0);\n            Some((rid, encoded_collab))\n          })\n          .collect::<Vec<(Rid, EncodedCollab)>>()\n      })\n      .unwrap_or_else(|err| {\n        error!(\n          \"Thread pool panic during batch encode collab processing: {}\",\n          err\n        );\n        vec![]\n      });\n\n    final_results\n  }\n\n  /// Retrieves a range of updates from the Redis stream for a given workspace ID.\n  pub async fn get_workspace_updates(\n    &self,\n    workspace_id: &Uuid,\n    object_id: Option<&Uuid>,\n    from: Option<Rid>,\n    to: Option<Rid>,\n  ) -> Result<Vec<UpdateStreamMessage>, AppError> {\n    let key = UpdateStreamMessage::stream_key(workspace_id);\n    let from = from\n      .map(|rid| rid.to_string())\n      .unwrap_or_else(|| \"-\".into());\n    let to = to.map(|rid| rid.to_string()).unwrap_or_else(|| \"+\".into());\n    let mut conn = self.connection_manager.clone();\n    let updates: StreamRangeReply = conn\n      .xrange(key, from, to)\n      .await\n      .map_err(|err| AppError::Internal(err.into()))?;\n    let mut result = Vec::with_capacity(updates.ids.len());\n    for stream_id in updates.ids {\n      if let Some(object_id) = object_id {\n        let msg_oid = stream_id\n          .map\n          .get(\"oid\")\n          .and_then(|v| Uuid::from_redis_value(v).ok())\n          .unwrap_or_default();\n        if &msg_oid != object_id {\n          continue; // this is not the object we are looking for\n        }\n      }\n      let message = UpdateStreamMessage::from_redis_stream(&stream_id.id, &stream_id.map)?;\n      result.push(message);\n    }\n    Ok(result)\n  }\n\n  #[instrument(level = \"trace\", skip_all, fields(object_id=%object_id))]\n  pub async fn insert_encode_collab(\n    &self,\n    object_id: &Uuid,\n    encoded_collab: EncodedCollab,\n    mills_secs: MillisSeconds,\n    expiration_seconds: u64,\n  ) {\n    trace!(\n      \"insert encode collab: {} updated_at: {}\",\n      object_id,\n      mills_secs\n    );\n    // Estimate the size of the encoded data to decide whether to spawn a blocking task\n    let estimated_size = encoded_collab.state_vector.len() + encoded_collab.doc_state.len();\n    let bytes_result = if estimated_size <= ENCODE_SPAWN_THRESHOLD {\n      // For small data, encode on current thread for efficiency\n      encoded_collab.encode_to_bytes()\n    } else {\n      // For large data, spawn a blocking task to avoid blocking current thread\n      match tokio::task::spawn_blocking(move || encoded_collab.encode_to_bytes()).await {\n        Ok(result) => result,\n        Err(e) => {\n          error!(\"Failed to spawn blocking task for encoding: {}\", e);\n          return;\n        },\n      }\n    };\n\n    match bytes_result {\n      Ok(bytes) => {\n        if let Err(err) = self\n          .insert_data_with_timestamp(object_id, &bytes, mills_secs, Some(expiration_seconds))\n          .await\n        {\n          error!(\"Failed to cache encoded collab: {}\", err);\n        }\n      },\n      Err(err) => {\n        error!(\"Failed to encode collab to bytes: {}\", err);\n      },\n    }\n  }\n\n  /// Inserts data into Redis with a conditional timestamp.\n  /// if the expiration_seconds is None, the data will be expired after 7 days.\n  pub async fn insert_encode_collab_data(\n    &self,\n    object_id: &Uuid,\n    data: &[u8],\n    millis_secs: MillisSeconds,\n    expiration_seconds: Option<u64>,\n  ) -> redis::RedisResult<()> {\n    self\n      .insert_data_with_timestamp(object_id, data, millis_secs, expiration_seconds)\n      .await\n  }\n\n  /// Batch inserts multiple raw data items efficiently using Redis pipeline.\n  ///\n  /// **Note: This function will override any existing values without timestamp comparison.**\n  /// Use the single insert methods if you need conditional insertion based on timestamps.\n  #[instrument(level = \"trace\", skip_all, fields(count = items.len()))]\n  pub async fn batch_insert_raw_data(\n    &self,\n    items: &[(Uuid, Bytes, MillisSeconds, Option<u64>)],\n  ) -> redis::RedisResult<()> {\n    if items.is_empty() {\n      return Ok(());\n    }\n\n    let mut conn = self.connection_manager.clone();\n    let mut pipeline = pipe();\n    pipeline.atomic();\n\n    // Prepare all data with timestamps and add to pipeline\n    for (object_id, data, timestamp, expiration_seconds) in items {\n      trace!(\"insert collab: {} updated_at: {}\", object_id, timestamp);\n\n      let cache_key = encode_collab_key(object_id);\n      let mut timestamped_data = Vec::with_capacity(8 + data.len());\n      timestamped_data.extend_from_slice(&timestamp.0.to_be_bytes());\n      timestamped_data.extend_from_slice(data);\n      pipeline.set(&cache_key, timestamped_data).ignore();\n\n      let expiration = expiration_seconds.unwrap_or(SEVEN_DAYS);\n      pipeline.expire(&cache_key, expiration as i64).ignore();\n    }\n\n    // Execute the batch insert\n    match pipeline.query_async::<()>(&mut conn).await {\n      Ok(()) => {\n        self\n          .metrics\n          .redis_write_collab_count\n          .inc_by(items.len() as u64);\n        Ok(())\n      },\n      Err(err) => {\n        error!(\"Failed to execute batch insert pipeline: {}\", err);\n        Err(redis::RedisError::from((\n          redis::ErrorKind::IoError,\n          \"Failed to execute batch insert pipeline\",\n        )))\n      },\n    }\n  }\n\n  /// Inserts data into Redis with a conditional timestamp.\n  ///\n  /// inserts data associated with an `object_id` into Redis only if the new timestamp is greater than the timestamp\n  /// currently stored in Redis for the same `object_id`. It uses Redis transactions to ensure that the operation is atomic.\n  /// the data will be expired after 7 days.\n  ///\n  /// For large data (bigger than small_collab_size), the insertion is spawned as an async task to avoid blocking the caller.\n  ///\n  /// # Arguments\n  /// * `object_id` - A string identifier for the data object.\n  /// * `data` - The binary data to be stored.\n  /// * `timestamp` - A unix timestamp indicating the creation time of the data.\n  ///\n  /// # Returns\n  /// A Redis result indicating the success or failure of the operation.\n  #[instrument(level = \"trace\", skip_all)]\n  async fn insert_data_with_timestamp(\n    &self,\n    object_id: &Uuid,\n    data: &[u8],\n    millis_secs: MillisSeconds,\n    expiration_seconds: Option<u64>,\n  ) -> redis::RedisResult<()> {\n    trace!(\"insert collab: {} updated_at: {}\", object_id, millis_secs,);\n    // Check if data size is larger than threshold\n    if data.len() > self.small_collab_size {\n      // For large data, spawn an async task to avoid blocking the caller\n      let cache = self.clone();\n      let object_id = *object_id;\n      let data = data.to_vec();\n      tokio::spawn(async move {\n        if let Err(err) = cache\n          ._insert_data_with_timestamp(&object_id, &data, millis_secs, expiration_seconds)\n          .await\n        {\n          error!(\"Failed to insert large data into cache: {}\", err);\n        }\n      });\n      // Return immediately for large data\n      return Ok(());\n    }\n\n    self\n      ._insert_data_with_timestamp(object_id, data, millis_secs, expiration_seconds)\n      .await\n  }\n\n  /// Internal implementation of data insertion with timestamp.\n  async fn _insert_data_with_timestamp(\n    &self,\n    object_id: &Uuid,\n    data: &[u8],\n    seconds: MillisSeconds,\n    expiration_seconds: Option<u64>,\n  ) -> redis::RedisResult<()> {\n    let cache_object_id = encode_collab_key(object_id);\n    let mut conn = self.connection_manager.clone();\n    let key_exists: bool = conn.exists(&cache_object_id).await?;\n    // Start a watch on the object_id to monitor for changes during this transaction\n    if key_exists {\n      // WATCH command is used to monitor one or more keys for modifications, establishing a condition\n      // for executing a subsequent transaction (with MULTI/EXEC). If any of the watched keys are\n      // altered by another client before the current client executes EXEC, the transaction will be\n      // aborted by Redis (the EXEC will return nil indicating the transaction was not processed).\n      let _: redis::Value = redis::cmd(\"WATCH\")\n        .arg(&cache_object_id)\n        .query_async(&mut conn)\n        .await?;\n    }\n\n    let result = async {\n      // Retrieve the current data, if exists\n      let current_value: Option<(u64, Vec<u8>)> = if key_exists {\n        let val: Option<Vec<u8>> = conn.get(&cache_object_id).await?;\n        val.and_then(|data| Self::extract_timestamp_and_payload(&data).ok())\n      } else {\n        None\n      };\n\n      // Perform update only if the new timestamp is greater than the existing one\n      if current_value\n        .as_ref()\n        .is_none_or(|(ts, _)| seconds.0 >= *ts)\n      {\n        let mut pipeline = pipe();\n        let mut timestamped_data = Vec::with_capacity(8 + data.len());\n        timestamped_data.extend_from_slice(&seconds.0.to_be_bytes());\n        timestamped_data.extend_from_slice(data);\n        pipeline\n            .atomic()\n            .set(&cache_object_id, timestamped_data)\n            .ignore()\n            .expire(&cache_object_id, expiration_seconds.unwrap_or(SEVEN_DAYS) as i64) // Setting the expiration to 7 days\n            .ignore();\n        let () = pipeline.query_async(&mut conn).await?;\n      }\n      Ok::<(), redis::RedisError>(())\n    }\n    .await;\n\n    // Always reset Watch State\n    let _: redis::Value = redis::cmd(\"UNWATCH\").query_async(&mut conn).await?;\n    self.metrics.redis_write_collab_count.inc();\n    result\n  }\n\n  /// Retrieves data and its associated timestamp from Redis for a given object identifier.\n  ///\n  /// # Arguments\n  /// * `object_id` - A unique identifier for the data.\n  ///\n  /// # Returns\n  /// A `RedisResult<Option<(i64, Vec<u8>)>>` where:\n  /// - `i64` is the timestamp of the data.\n  /// - `Vec<u8>` is the binary data.\n  ///\n  /// The function returns `Ok(None)` if no data is found for the given `object_id`.\n  pub async fn get_data_with_timestamp(\n    &self,\n    object_id: &Uuid,\n  ) -> redis::RedisResult<Option<(u64, Vec<u8>)>> {\n    let cache_object_id = encode_collab_key(object_id);\n    let mut conn = self.connection_manager.clone();\n\n    // Attempt to retrieve the data from Redis\n    if let Some(data) = conn.get::<_, Option<Vec<u8>>>(&cache_object_id).await? {\n      // Use helper function to extract timestamp and payload with metrics\n      match self.extract_timestamp_and_payload_with_metrics(&data, Some(object_id)) {\n        Some((timestamp, payload)) => Ok(Some((timestamp, payload))),\n        None => Err(redis::RedisError::from((\n          redis::ErrorKind::TypeError,\n          \"Failed to extract timestamp and payload\",\n        ))),\n      }\n    } else {\n      // No data found for the provided object_id\n      Ok(None)\n    }\n  }\n\n  /// Helper function to extract timestamp and payload from raw Redis data.\n  ///\n  /// # Arguments\n  /// * `data` - Raw bytes from Redis containing timestamp (first 8 bytes) + payload\n  ///\n  /// # Returns\n  /// * `Ok((timestamp, payload))` - Successfully extracted timestamp and payload\n  /// * `Err(redis::RedisError)` - If data is corrupted or too short\n  fn extract_timestamp_and_payload(data: &[u8]) -> redis::RedisResult<(u64, Vec<u8>)> {\n    if data.len() < 8 {\n      return Err(redis::RedisError::from((\n        redis::ErrorKind::TypeError,\n        \"Data corruption: stored data is too short to contain a valid timestamp.\",\n      )));\n    }\n\n    match data[0..8].try_into() {\n      Ok(ts_bytes) => {\n        let timestamp = u64::from_be_bytes(ts_bytes);\n        let payload = data[8..].to_vec();\n        Ok((timestamp, payload))\n      },\n      Err(_) => Err(redis::RedisError::from((\n        redis::ErrorKind::TypeError,\n        \"Failed to decode timestamp\",\n      ))),\n    }\n  }\n\n  /// Helper function to extract timestamp and payload with metrics tracking.\n  ///\n  /// # Arguments\n  /// * `data` - Raw bytes from Redis\n  ///\n  /// # Returns\n  /// * `Some((timestamp, payload))` - Successfully extracted\n  /// * `None` - If extraction failed (errors are logged)\n  fn extract_timestamp_and_payload_with_metrics(\n    &self,\n    data: &[u8],\n    object_id: Option<&Uuid>,\n  ) -> Option<(u64, Vec<u8>)> {\n    match Self::extract_timestamp_and_payload(data) {\n      Ok((timestamp, payload)) => {\n        self.metrics.redis_read_collab_count.inc();\n        Some((timestamp, payload))\n      },\n      Err(err) => {\n        if let Some(oid) = object_id {\n          error!(\"Failed to extract timestamp/payload for {}: {}\", oid, err);\n        } else {\n          error!(\"Failed to extract timestamp/payload: {}\", err);\n        }\n        None\n      },\n    }\n  }\n\n  fn extract_and_decode_collab(\n    &self,\n    data: &[u8],\n    object_id: Option<&Uuid>,\n  ) -> Option<(u64, EncodedCollab)> {\n    // Extract timestamp and payload with metrics\n    let (timestamp, payload) = self.extract_timestamp_and_payload_with_metrics(data, object_id)?;\n\n    // Decode the collaboration data\n    match encode_collab_from_bytes(payload) {\n      Ok(encoded_collab) => Some((timestamp, encoded_collab)),\n      Err(err) => {\n        if let Some(oid) = object_id {\n          error!(\"Failed to decode collab data for {}: {}\", oid, err);\n        } else {\n          error!(\"Failed to decode collab data: {}\", err);\n        }\n        None\n      },\n    }\n  }\n}\n\n/// Generates a cache-specific key for an object ID by prepending a fixed prefix.\n/// This method ensures that any updates to the object's data involve merely\n/// changing the prefix, allowing the old data to expire naturally.\n#[inline]\nfn encode_collab_key(object_id: &Uuid) -> String {\n  let mut key = String::with_capacity(52); // \"encode_collab_v0:\".len() + 36 (UUID length)\n  key.push_str(\"encode_collab_v0:\");\n  key.push_str(&object_id.to_string());\n  key\n}\n\n#[inline]\npub fn cache_exp_secs_from_collab_type(collab_type: &CollabType) -> u64 {\n  match collab_type {\n    CollabType::Document => SEVEN_DAYS * 2,\n    CollabType::Database => SEVEN_DAYS * 2,\n    CollabType::WorkspaceDatabase => ONE_MONTH,\n    CollabType::Folder => SEVEN_DAYS,\n    CollabType::DatabaseRow => SEVEN_DAYS,\n    CollabType::UserAwareness => SEVEN_DAYS * 2,\n    CollabType::Unknown => SEVEN_DAYS,\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/cache/mod.rs",
    "content": "mod collab_cache;\npub mod disk_cache;\npub mod mem_cache;\n\nuse app_error::AppError;\nuse collab::entity::EncodedCollab;\npub use collab_cache::CollabCache;\nuse infra::thread_pool::ThreadPoolNoAbort;\nuse std::sync::Arc;\n/// Threshold for spawning blocking tasks for decoding operations.\n/// Data smaller than this will be processed on the current thread for efficiency.\n/// Data larger than this will be spawned to avoid blocking the current thread.\npub const DECODE_SPAWN_THRESHOLD: usize = 4096; // 4KB\n\n#[inline]\npub(crate) fn encode_collab_from_bytes(bytes: Vec<u8>) -> Result<EncodedCollab, AppError> {\n  match EncodedCollab::decode_from_bytes(&bytes) {\n    Ok(encoded_collab) => Ok(encoded_collab),\n    Err(err) => Err(AppError::Internal(anyhow::anyhow!(\n      \"Failed to decode collab from bytes: {:?}\",\n      err\n    ))),\n  }\n}\n\n#[inline]\npub(crate) async fn encode_collab_from_bytes_with_thread_pool(\n  thread_pool: &Arc<ThreadPoolNoAbort>,\n  bytes: Vec<u8>,\n) -> Result<EncodedCollab, AppError> {\n  if bytes.len() <= DECODE_SPAWN_THRESHOLD {\n    // For small data, decode on current thread for efficiency\n    match EncodedCollab::decode_from_bytes(&bytes) {\n      Ok(encoded_collab) => Ok(encoded_collab),\n      Err(err) => Err(AppError::Internal(anyhow::anyhow!(\n        \"Failed to decode collab from bytes: {:?}\",\n        err\n      ))),\n    }\n  } else {\n    thread_pool\n      .install(|| match EncodedCollab::decode_from_bytes(&bytes) {\n        Ok(encoded_collab) => Ok(encoded_collab),\n        Err(err) => Err(AppError::Internal(anyhow::anyhow!(\n          \"Failed to decode collab from bytes: {:?}\",\n          err\n        ))),\n      })\n      .map_err(|err| {\n        AppError::Internal(anyhow::anyhow!(\"Failed to spawn blocking task: {:?}\", err))\n      })?\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/collab_manager.rs",
    "content": "use crate::collab::cache::mem_cache::MillisSeconds;\nuse crate::collab::cache::CollabCache;\nuse access_control::act::Action;\nuse access_control::collab::CollabAccessControl;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_proto::{ObjectId, Rid, TimestampedEncodedCollab, UpdateFlags, WorkspaceId};\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse collab::preclude::Collab;\nuse collab_document::document::DocumentBody;\nuse collab_entity::CollabType;\nuse collab_folder::Folder;\nuse collab_stream::awareness_gossip::AwarenessGossip;\nuse collab_stream::lease::Lease;\nuse collab_stream::model::{AwarenessStreamUpdate, MessageId, UpdateStreamMessage};\nuse collab_stream::stream_router::StreamRouter;\nuse database::collab::AppResult;\nuse database_entity::dto::{CollabParams, CollabUpdateData, QueryCollab};\nuse indexer::scheduler::{IndexerScheduler, UnindexedCollabTask, UnindexedData};\nuse infra::thread_pool::ThreadPoolNoAbort;\nuse itertools::Itertools;\nuse rayon::prelude::*;\nuse redis::aio::ConnectionManager;\nuse redis::streams::{StreamTrimOptions, StreamTrimmingMode};\nuse redis::AsyncCommands;\nuse std::collections::HashMap;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse std::time::Duration;\n/// Represents a snapshot task to be processed\nuse tracing::{instrument, trace, warn};\nuse uuid::Uuid;\nuse yrs::block::ClientID;\nuse yrs::sync::AwarenessUpdate;\nuse yrs::updates::decoder::Decode;\nuse yrs::updates::encoder::Encode;\nuse yrs::{ReadTxn, StateVector, Update};\n\npub struct CollabManager {\n  collab_cache: Arc<CollabCache>,\n  access_control: Arc<dyn CollabAccessControl>,\n  update_streams: Arc<StreamRouter>,\n  awareness_broadcast: Arc<AwarenessGossip>,\n  connection_manager: ConnectionManager,\n  indexer_scheduler: Arc<IndexerScheduler>,\n  snapshot_thread_pool: Arc<ThreadPoolNoAbort>,\n}\n\nimpl CollabManager {\n  #[allow(clippy::too_many_arguments)]\n  pub fn new(\n    thread_pool: Arc<ThreadPoolNoAbort>,\n    access_control: Arc<dyn CollabAccessControl>,\n    collab_cache: Arc<CollabCache>,\n    connection_manager: ConnectionManager,\n    update_streams: Arc<StreamRouter>,\n    awareness_broadcast: Arc<AwarenessGossip>,\n    indexer_scheduler: Arc<IndexerScheduler>,\n  ) -> Arc<Self> {\n    Arc::new(Self {\n      access_control,\n      collab_cache,\n      update_streams,\n      awareness_broadcast,\n      connection_manager,\n      indexer_scheduler,\n      snapshot_thread_pool: thread_pool,\n    })\n  }\n\n  pub fn updates(&self) -> &StreamRouter {\n    &self.update_streams\n  }\n\n  pub fn awareness(&self) -> &AwarenessGossip {\n    &self.awareness_broadcast\n  }\n\n  async fn prune_updates(&self, workspace_id: WorkspaceId, up_to: Rid) -> anyhow::Result<()> {\n    let key = UpdateStreamMessage::stream_key(&workspace_id);\n    let mut conn = self.connection_manager.clone();\n    let options = StreamTrimOptions::minid(StreamTrimmingMode::Exact, up_to.to_string());\n    let _: redis::Value = conn.xtrim_options(key, &options).await?;\n    tracing::info!(\"pruned updates from workspace {}\", workspace_id);\n    Ok(())\n  }\n\n  pub async fn build_folder(&self, workspace_id: WorkspaceId) -> AppResult<Folder> {\n    let query = QueryCollab::new(workspace_id, CollabType::Folder);\n    let encoded_collab = self\n      .collab_cache\n      .get_full_collab(&workspace_id, query, None, EncoderVersion::V1)\n      .await?\n      .encoded_collab;\n\n    let folder = tokio::task::spawn_blocking(move || {\n      Folder::from_collab_doc_state(\n        CollabOrigin::Server,\n        encoded_collab.into(),\n        &workspace_id.to_string(),\n        default_client_id(),\n      )\n      .map_err(|e| {\n        AppError::Internal(anyhow::anyhow!(\n          \"Unable to decode workspace folder {}: {}\",\n          workspace_id,\n          e\n        ))\n      })\n    })\n    .await??;\n    Ok(folder)\n  }\n\n  async fn get_snapshot(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    collab_type: CollabType,\n  ) -> AppResult<(Rid, Bytes)> {\n    match self\n      .collab_cache\n      .get_snapshot_collab(&workspace_id, QueryCollab::new(object_id, collab_type))\n      .await\n    {\n      Ok((rid, collab)) => Ok((rid, collab.doc_state)),\n      Err(AppError::RecordNotFound(_)) => Ok((Rid::default(), Bytes::from_static(&[0, 0]))),\n      Err(err) => Err(err),\n    }\n  }\n\n  pub async fn enforce_read_collab(\n    &self,\n    workspace_id: &WorkspaceId,\n    uid: &i64,\n    object_id: &ObjectId,\n  ) -> AppResult<()> {\n    let collab_exists = self.collab_cache.is_exist(workspace_id, object_id).await?;\n    if !collab_exists {\n      // If the collab does not exist, we should not enforce the access control. We consider the user\n      // has the permission to read the collab\n      return Ok(());\n    }\n    self\n      .access_control\n      .enforce_action(workspace_id, uid, object_id, Action::Read)\n      .await\n  }\n\n  pub async fn enforce_write_collab(\n    &self,\n    workspace_id: &WorkspaceId,\n    uid: &i64,\n    object_id: &ObjectId,\n  ) -> AppResult<()> {\n    let collab_exists = self.collab_cache.is_exist(workspace_id, object_id).await?;\n    if !collab_exists {\n      // If the collab does not exist, we should not enforce the access control. we consider the user\n      // has the permission to write the collab\n      return Ok(());\n    }\n    self\n      .access_control\n      .enforce_action(workspace_id, uid, object_id, Action::Write)\n      .await\n  }\n\n  pub async fn get_collabs_created_since(\n    &self,\n    workspace_id: Uuid,\n    since: DateTime<Utc>,\n    limit: usize,\n  ) -> Result<Vec<CollabUpdateData>, AppError> {\n    self\n      .collab_cache\n      .get_collabs_created_since(workspace_id, since, limit)\n      .await\n  }\n\n  /// Returns the latest full state of an object (including all updates).\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn get_latest_state(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    user_id: i64,\n    state_vector: StateVector,\n  ) -> AppResult<CollabState> {\n    let params = QueryCollab::new(object_id, collab_type);\n    let collab_exists = self\n      .collab_cache\n      .is_exist(&workspace_id, &object_id)\n      .await?;\n    if collab_exists {\n      self\n        .access_control\n        .enforce_action(&workspace_id, &user_id, &object_id, Action::Read)\n        .await?;\n    }\n\n    let TimestampedEncodedCollab {\n      encoded_collab,\n      rid,\n    } = self\n      .collab_cache\n      .get_full_collab(\n        &workspace_id,\n        params,\n        Some(state_vector),\n        EncoderVersion::V1,\n      )\n      .await?;\n\n    Ok(CollabState {\n      rid,\n      flags: UpdateFlags::Lib0v1,\n      update: encoded_collab.doc_state,\n      state_vector: encoded_collab.state_vector.into(),\n    })\n  }\n\n  pub async fn get_workspace_updates(\n    &self,\n    workspace_id: &WorkspaceId,\n    since: MessageId,\n  ) -> AppResult<Vec<UpdateStreamMessage>> {\n    self\n      .collab_cache\n      .get_workspace_updates(workspace_id, None, Some(since.into()), None)\n      .await\n  }\n\n  #[instrument(level = \"trace\", skip_all, err)]\n  pub async fn publish_update(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    sender: &CollabOrigin,\n    update: Vec<u8>,\n  ) -> anyhow::Result<Rid> {\n    trace!(\n      \"publish_update({:?}, {}/{}, update: {:#?})\",\n      workspace_id,\n      object_id,\n      collab_type,\n      yrs::Update::decode_v1(&update)\n    );\n\n    let key = UpdateStreamMessage::stream_key(&workspace_id);\n    let mut conn = self.connection_manager.clone();\n    let rid: String = UpdateStreamMessage::prepare_command(\n      &key,\n      &object_id,\n      collab_type,\n      sender,\n      update,\n      UpdateFlags::Lib0v1.into(),\n    )\n    .query_async(&mut conn)\n    .await?;\n    let rid = Rid::from_str(&rid).map_err(|err| anyhow!(\"failed to parse rid: {}\", err))?;\n    self\n      .collab_cache\n      .mark_as_dirty(object_id, MillisSeconds::from(rid.timestamp));\n    trace!(\n      \"published update to '{}' (object id: {}), rid:{}\",\n      key,\n      object_id,\n      rid\n    );\n    Ok(rid)\n  }\n\n  pub fn mark_as_dirty(&self, object_id: ObjectId, millis_secs: u64) {\n    self\n      .collab_cache\n      .mark_as_dirty(object_id, MillisSeconds::from(millis_secs));\n  }\n\n  #[instrument(level = \"trace\", skip_all, err)]\n  pub async fn publish_awareness_update(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    sender: CollabOrigin,\n    update: AwarenessUpdate,\n  ) -> anyhow::Result<()> {\n    let msg = AwarenessStreamUpdate {\n      data: update,\n      sender,\n    };\n    trace!(\"broadcast {} awareness update\", object_id);\n    self\n      .awareness_broadcast\n      .send(&workspace_id.to_string(), &object_id.to_string(), &msg)\n      .await?;\n    Ok(())\n  }\n\n  /// Snapshots all pending updates for a workspace into persistent storage.\n  ///\n  /// This operation:\n  /// - Acquires a distributed lock to prevent concurrent snapshots\n  /// - Processes collabs in parallel using the thread pool\n  /// - Groups and batch-inserts snapshots by user ID\n  /// - Schedules updated documents for search indexing\n  /// - Prunes processed updates from Redis streams\n  #[instrument(level = \"trace\", skip_all)]\n  pub async fn snapshot_workspace(\n    &self,\n    workspace_id: WorkspaceId,\n    mut up_to: Rid,\n  ) -> anyhow::Result<()> {\n    let all_object_updates = self\n      .collab_cache\n      .get_workspace_updates(&workspace_id, None, None, Some(up_to))\n      .await?\n      .into_iter()\n      .into_group_map_by(|msg| msg.object_id);\n\n    if all_object_updates.is_empty() {\n      trace!(\n        \"no updates to snapshot for workspace {} up to rid {}\",\n        workspace_id,\n        up_to\n      );\n      return Ok(());\n    }\n\n    if let Some(_lease) = self.acquire_workspace_lock(workspace_id).await? {\n      let snapshot_start = std::time::Instant::now();\n\n      // Count total updates for metrics\n      let total_updates: usize = all_object_updates\n        .values()\n        .map(|updates| updates.len())\n        .sum();\n\n      tracing::debug!(\n        \"snapshotting {} collabs ({} total updates) for workspace {}\",\n        all_object_updates.len(),\n        total_updates,\n        workspace_id\n      );\n\n      let snapshot_tasks = self\n        .collect_snapshot_tasks(workspace_id, all_object_updates)\n        .await?;\n      if snapshot_tasks.is_empty() {\n        tracing::info!(\"no collabs to snapshot for workspace {}\", workspace_id);\n        return Ok(());\n      }\n\n      let processing_results = self.process_snapshot_tasks(snapshot_tasks)?;\n      self\n        .encode_and_save_snapshots(workspace_id, processing_results)\n        .await?;\n\n      // Cleanup: prune processed updates from Redis stream\n      up_to.seq_no += 1;\n      self.prune_updates(workspace_id, up_to).await?;\n\n      // Record metrics for the completed snapshot operation\n      let snapshot_duration = snapshot_start.elapsed();\n      self\n        .collab_cache\n        .metrics()\n        .observe_snapshot_duration(snapshot_duration);\n      self\n        .collab_cache\n        .metrics()\n        .observe_snapshot_updates_count(total_updates);\n\n      tracing::debug!(\n        \"completed snapshot for workspace {} in {:?} (processed {} updates)\",\n        workspace_id,\n        snapshot_duration,\n        total_updates\n      );\n    }\n    Ok(())\n  }\n\n  /// Acquires a distributed lock for workspace snapshotting\n  async fn acquire_workspace_lock(\n    &self,\n    workspace_id: WorkspaceId,\n  ) -> anyhow::Result<Option<impl Drop>> {\n    let key = format!(\"af:lease:{}\", workspace_id);\n    // Acquire distributed lock for this workspace using Redis.\n    // This ensures only one server can snapshot a workspace at a time, preventing:\n    // - Race conditions between multiple servers\n    // - Duplicate processing work\n    // - Conflicts when pruning Redis streams\n    //\n    // The lock expires after 120 seconds if not released (fault tolerance).\n    self\n      .connection_manager\n      .lease(key, Duration::from_secs(120))\n      .await\n      .map_err(|e| anyhow!(\"Failed to acquire workspace lock: {}\", e))\n  }\n\n  /// Collects all snapshot tasks for the workspace\n  async fn collect_snapshot_tasks(\n    &self,\n    workspace_id: WorkspaceId,\n    all_object_updates: HashMap<Uuid, Vec<UpdateStreamMessage>>,\n  ) -> anyhow::Result<Vec<SnapshotTask>> {\n    let mut snapshot_tasks = Vec::new();\n    for (object_id, updates) in all_object_updates {\n      if updates.is_empty() {\n        continue;\n      }\n\n      let (collab_type, user_id) = {\n        let update = &updates[0];\n        (\n          update.collab_type,\n          update.sender.client_user_id().unwrap_or(0),\n        )\n      };\n\n      // Fetch the snapshot from database\n      let (rid, update_snapshot) = self\n        .get_snapshot(workspace_id, object_id, collab_type)\n        .await?;\n\n      snapshot_tasks.push(SnapshotTask {\n        workspace_id,\n        object_id,\n        collab_type,\n        user_id,\n        rid,\n        update_snapshot,\n        updates,\n      });\n    }\n\n    Ok(snapshot_tasks)\n  }\n\n  /// Processes snapshot tasks in parallel to generate full states\n  fn process_snapshot_tasks(\n    &self,\n    snapshot_tasks: Vec<SnapshotTask>,\n  ) -> anyhow::Result<Vec<ProcessedSnapshot>> {\n    let thread_pool = self.snapshot_thread_pool.clone();\n    let client_id = default_client_id();\n\n    thread_pool\n      .install(|| {\n        snapshot_tasks\n          .into_par_iter()\n          .filter_map(|task| {\n            match apply_updates_to_snapshot(\n              client_id,\n              task.object_id,\n              task.collab_type,\n              task.rid,\n              task.update_snapshot,\n              task.updates,\n            ) {\n              Ok((rid, full_state, state_vector, paragraphs)) => Some(ProcessedSnapshot {\n                workspace_id: task.workspace_id,\n                object_id: task.object_id,\n                collab_type: task.collab_type,\n                user_id: task.user_id,\n                rid,\n                full_state,\n                state_vector: state_vector.encode_v1().into(),\n                paragraphs,\n              }),\n              Err(err) => {\n                warn!(\n                  \"Failed to process collab {} snapshot: {}\",\n                  task.object_id, err\n                );\n                None\n              },\n            }\n          })\n          .collect::<Vec<_>>()\n      })\n      .map_err(|err| anyhow!(\"Thread pool panic during snapshot processing: {}\", err))\n  }\n\n  /// Encodes collabs in parallel and saves them in batches grouped by user ID\n  async fn encode_and_save_snapshots(\n    &self,\n    workspace_id: WorkspaceId,\n    processing_results: Vec<ProcessedSnapshot>,\n  ) -> anyhow::Result<()> {\n    const BATCH_SIZE: usize = 20;\n    let mut indexed_collabs = vec![];\n\n    for chunk in processing_results.chunks(BATCH_SIZE) {\n      trace!(\n        \"processing batch of {} collabs when snapshotting workspace {}\",\n        chunk.len(),\n        workspace_id\n      );\n\n      let encoded_result = self.encode_collab_chunk(chunk)?;\n\n      // Collect indexing tasks\n      for task in encoded_result.indexing_tasks {\n        indexed_collabs.push(task);\n      }\n\n      // Batch insert snapshots for each user\n      self\n        .batch_insert_by_user(workspace_id, encoded_result.params_by_uid)\n        .await;\n\n      // Batch index collabs periodically\n      if !indexed_collabs.is_empty() {\n        self.batch_index_collabs(workspace_id, &mut indexed_collabs);\n      }\n    }\n\n    Ok(())\n  }\n\n  /// Encodes a chunk of collabs in parallel\n  fn encode_collab_chunk(&self, chunk: &[ProcessedSnapshot]) -> anyhow::Result<EncodedChunkResult> {\n    // Prepare data for parallel encoding\n    let encode_data: Vec<_> = chunk\n      .iter()\n      .map(|snapshot| {\n        (\n          snapshot.workspace_id,\n          snapshot.object_id,\n          snapshot.collab_type,\n          snapshot.user_id,\n          snapshot.rid,\n          snapshot.full_state.clone(),\n          snapshot.state_vector.clone(),\n          snapshot.paragraphs.clone(),\n        )\n      })\n      .collect();\n\n    // Use thread pool to encode all collabs in parallel\n    let batch_results = self\n      .snapshot_thread_pool\n      .install(|| {\n        encode_data\n          .into_par_iter()\n          .map(\n            |(ws_id, object_id, collab_type, uid, rid, full_state, state_vector, paragraphs)| {\n              // Create EncodedCollab and encode to bytes in parallel\n              let encoded_collab = EncodedCollab::new_v1(state_vector, full_state);\n              let updated_at = DateTime::<Utc>::from_timestamp_millis(rid.timestamp as i64);\n              let encoded_bytes = encoded_collab\n                .encode_to_bytes()\n                .map_err(|e| anyhow!(\"Failed to encode collab {}: {}\", object_id, e))?;\n\n              let params = CollabParams {\n                object_id,\n                encoded_collab_v1: encoded_bytes.into(),\n                collab_type,\n                updated_at,\n              };\n\n              // Return both params and indexing data\n              let indexing_data = if !paragraphs.is_empty() {\n                Some(UnindexedCollabTask::new(\n                  ws_id,\n                  object_id,\n                  collab_type,\n                  UnindexedData::Paragraphs(paragraphs),\n                ))\n              } else {\n                None\n              };\n\n              Ok(((uid, params), indexing_data))\n            },\n          )\n          .collect::<Result<Vec<_>, anyhow::Error>>()\n      })\n      .map_err(|err| anyhow!(\"Thread pool panic during encoding: {}\", err))??;\n\n    // Separate params with uid and indexing data\n    let (params_with_uid, indexing_tasks): (Vec<_>, Vec<_>) = batch_results.into_iter().unzip();\n\n    // Group batch params by uid\n    let mut batch_params_by_uid: HashMap<i64, Vec<CollabParams>> = HashMap::new();\n    for (uid, params) in params_with_uid {\n      batch_params_by_uid.entry(uid).or_default().push(params);\n    }\n\n    // Collect non-None indexing tasks\n    let indexing_tasks: Vec<UnindexedCollabTask> = indexing_tasks.into_iter().flatten().collect();\n    Ok(EncodedChunkResult {\n      params_by_uid: batch_params_by_uid,\n      indexing_tasks,\n    })\n  }\n\n  /// Batch inserts snapshots grouped by user ID\n  async fn batch_insert_by_user(\n    &self,\n    workspace_id: WorkspaceId,\n    batch_params_by_uid: HashMap<i64, Vec<CollabParams>>,\n  ) {\n    for (uid, batch_params) in batch_params_by_uid {\n      if !batch_params.is_empty() {\n        let batch_len = batch_params.len();\n        if let Err(err) = self\n          .collab_cache\n          .bulk_insert_collab(workspace_id, &uid, batch_params)\n          .await\n        {\n          warn!(\n            \"Failed to batch insert {} snapshots for user {}: {}\",\n            batch_len, uid, err\n          );\n        } else {\n          trace!(\n            \"Successfully batch inserted {} snapshots for user {}\",\n            batch_len,\n            uid\n          );\n        }\n      }\n    }\n  }\n\n  /// Batch indexes collabs for search\n  fn batch_index_collabs(\n    &self,\n    workspace_id: WorkspaceId,\n    indexed_collabs: &mut Vec<UnindexedCollabTask>,\n  ) {\n    if !indexed_collabs.is_empty() {\n      trace!(\n        \"batch indexing {} collabs when snapshotting workspace {}\",\n        indexed_collabs.len(),\n        workspace_id\n      );\n      if let Err(err) = self\n        .indexer_scheduler\n        .index_pending_collabs(std::mem::take(indexed_collabs))\n      {\n        warn!(\"failed to batch index {}, err: {}\", workspace_id, err);\n      }\n    }\n  }\n}\n\npub struct CollabState {\n  pub rid: Rid,\n  pub flags: UpdateFlags,\n  pub update: Bytes,\n  pub state_vector: Vec<u8>,\n}\n\nfn apply_updates_to_snapshot(\n  client_id: ClientID,\n  object_id: ObjectId,\n  collab_type: CollabType,\n  rid_snapshot: Rid,\n  update_snapshot: Bytes,\n  updates: Vec<UpdateStreamMessage>,\n) -> anyhow::Result<(Rid, Bytes, StateVector, Vec<String>)> {\n  let options = CollabOptions::new(object_id.to_string(), client_id);\n  let mut collab = Collab::new_with_options(CollabOrigin::Server, options)\n    .map_err(|err| anyhow!(\"failed to create collab: {}\", err))?;\n\n  // Apply all updates to build the full state\n  let mut rid = rid_snapshot;\n  {\n    let mut tx = collab.transact_mut();\n    // First apply the snapshot\n    if !update_snapshot.is_empty() {\n      tx.apply_update(decode_update(&update_snapshot)?)?;\n    }\n\n    // Then apply updates from redis stream\n    trace!(\n      \"processing {} updates for {}:{}\",\n      updates.len(),\n      object_id,\n      collab_type\n    );\n\n    for update in updates {\n      if update.object_id == object_id {\n        rid = rid.max(update.last_message_id);\n        let update = match update.update_flags {\n          UpdateFlags::Lib0v1 => Update::decode_v1(&update.update),\n          UpdateFlags::Lib0v2 => Update::decode_v2(&update.update),\n        }?;\n        tx.apply_update(update)?;\n      }\n    }\n    drop(tx); // commit the transaction\n  }\n  let tx = collab.transact();\n  let full_state = tx.encode_diff_v1(&StateVector::default());\n  let state_vector = tx.state_vector();\n  let paragraphs = if collab_type == CollabType::Document {\n    DocumentBody::from_collab(&collab)\n      .map(|body| body.to_plain_text(tx))\n      .unwrap_or_default()\n  } else {\n    vec![]\n  };\n\n  Ok((rid, full_state.into(), state_vector, paragraphs))\n}\n\npub fn decode_update(update: &[u8]) -> AppResult<Update> {\n  if update.len() < 2 {\n    return Err(AppError::DecodeUpdateError(\"invalid update\".to_string()));\n  }\n  let encoding_hint = update[0];\n  let res = match encoding_hint {\n    // lib0 v2 update always starts with 0 (but v1 can too on delete-only updates)\n    0 => Update::decode_v2(update),\n    _ => Update::decode_v1(update),\n  };\n  match res {\n    Ok(res) => Ok(res),\n    Err(_) if encoding_hint == 0 => {\n      // we assumed that this was v2 encoded update, but it was not\n      Update::decode_v1(update).map_err(|e| AppError::DecodeUpdateError(e.to_string()))\n    },\n    Err(e) => Err(AppError::DecodeUpdateError(e.to_string())),\n  }\n}\n\nstruct SnapshotTask {\n  workspace_id: WorkspaceId,\n  object_id: ObjectId,\n  collab_type: CollabType,\n  user_id: i64,\n  rid: Rid,\n  update_snapshot: Bytes,\n  updates: Vec<UpdateStreamMessage>,\n}\n\nstruct ProcessedSnapshot {\n  workspace_id: WorkspaceId,\n  object_id: ObjectId,\n  collab_type: CollabType,\n  user_id: i64,\n  rid: Rid,\n  full_state: Bytes,\n  state_vector: Bytes,\n  paragraphs: Vec<String>,\n}\nstruct EncodedChunkResult {\n  /// Collab parameters grouped by user ID for batch insertion\n  params_by_uid: HashMap<i64, Vec<CollabParams>>,\n  /// Indexing tasks for search engine updates\n  indexing_tasks: Vec<UnindexedCollabTask>,\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/collab_store.rs",
    "content": "#![allow(unused_imports)]\n\nuse crate::collab::cache::mem_cache::MillisSeconds;\nuse crate::collab::cache::CollabCache;\nuse crate::metrics::CollabMetrics;\nuse crate::ws2::CollabManager;\nuse access_control::act::Action;\nuse access_control::collab::CollabAccessControl;\nuse access_control::workspace::WorkspaceAccessControl;\nuse anyhow::{anyhow, Context};\nuse app_error::AppError;\nuse appflowy_proto::{ObjectId, Rid, TimestampedEncodedCollab, UpdateFlags, WorkspaceId};\nuse async_trait::async_trait;\nuse chrono::Timelike;\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse collab_entity::CollabType;\nuse collab_rt_entity::ClientCollabMessage;\nuse collab_rt_protocol::validate_encode_collab;\nuse database::collab::{\n  insert_into_af_collab_bulk_for_user, AppResult, CollabMetadata, CollabStore, GetCollabOrigin,\n};\nuse database_entity::dto::{\n  AFAccessLevel, AFSnapshotMeta, AFSnapshotMetas, CollabParams, InsertSnapshotParams,\n  PendingCollabWrite, QueryCollab, QueryCollabParams, QueryCollabResult, SnapshotData,\n};\nuse itertools::{Either, Itertools};\nuse rayon::iter::{IntoParallelIterator, ParallelIterator};\nuse sqlx::Transaction;\nuse std::collections::HashMap;\nuse std::ops::DerefMut;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\nuse tokio::time::timeout;\nuse tracing::warn;\nuse tracing::{error, instrument, trace};\nuse uuid::Uuid;\nuse validator::Validate;\nuse yrs::{StateVector, Update};\n\n/// A wrapper around the actual storage implementation that provides access control and caching.\n#[derive(Clone)]\npub struct CollabStoreImpl {\n  cache: Arc<CollabCache>,\n  /// access control for collab object. Including read/write\n  access_control: Arc<dyn CollabAccessControl>,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  queue: Sender<PendingCollabWrite>,\n}\n\nimpl CollabStoreImpl {\n  pub fn new(\n    cache: Arc<CollabCache>,\n    access_control: Arc<dyn CollabAccessControl>,\n    workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  ) -> Self {\n    let (queue, reader) = channel(1000);\n    tokio::spawn(Self::periodic_write_task(cache.clone(), reader));\n    Self {\n      cache,\n      access_control,\n      workspace_access_control,\n      queue,\n    }\n  }\n\n  async fn check_or_update_permission(\n    &self,\n    uid: &i64,\n    workspace_id: &WorkspaceId,\n    object_id: &ObjectId,\n  ) -> AppResult<()> {\n    let is_exist = self.cache.is_exist(workspace_id, object_id).await?;\n    // If the collab already exists, check if the user has enough permissions to update collab\n    // Otherwise, check if the user has enough permissions to create collab.\n    if is_exist {\n      self\n        .access_control\n        .enforce_action(workspace_id, uid, object_id, Action::Write)\n        .await?;\n    } else {\n      self\n        .check_write_workspace_permission(workspace_id, uid)\n        .await?;\n      trace!(\n        \"Update policy for user:{} to create collab:{}\",\n        uid,\n        object_id\n      );\n      self\n        .access_control\n        .update_access_level_policy(uid, object_id, AFAccessLevel::FullAccess)\n        .await?;\n    }\n\n    Ok(())\n  }\n\n  pub fn metrics(&self) -> &CollabMetrics {\n    self.cache.metrics()\n  }\n\n  const PENDING_WRITE_BUF_CAPACITY: usize = 20;\n  async fn periodic_write_task(cache: Arc<CollabCache>, mut reader: Receiver<PendingCollabWrite>) {\n    let mut buf = Vec::with_capacity(Self::PENDING_WRITE_BUF_CAPACITY);\n    loop {\n      let n = reader\n        .recv_many(&mut buf, Self::PENDING_WRITE_BUF_CAPACITY)\n        .await;\n      if n == 0 {\n        break;\n      }\n      let pending = buf.drain(..n);\n      trace!(\"Persisting {} collabs to disk\", n);\n      if let Err(e) = cache.batch_insert_collab(pending.collect()).await {\n        error!(\"failed to persist {} collabs: {}\", n, e);\n      }\n    }\n  }\n\n  async fn check_write_workspace_permission(\n    &self,\n    workspace_id: &Uuid,\n    uid: &i64,\n  ) -> Result<(), AppError> {\n    // If the collab doesn't exist, check if the user has enough permissions to create collab.\n    // If the user is the owner or member of the workspace, the user can create collab.\n    self\n      .workspace_access_control\n      .enforce_action(uid, workspace_id, Action::Write)\n      .await?;\n    Ok(())\n  }\n\n  /// **Note: This function will override any existing values without timestamp comparison.**\n  /// Use the single insert methods if you need conditional insertion based on timestamps.\n  async fn batch_insert_collabs(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params_list: Vec<CollabParams>,\n  ) -> Result<(), AppError> {\n    self\n      .cache\n      .bulk_insert_collab(workspace_id, uid, params_list)\n      .await\n  }\n}\n\n#[async_trait]\nimpl CollabStore for CollabStoreImpl {\n  async fn upsert_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> AppResult<()> {\n    self\n      .check_or_update_permission(uid, &workspace_id, &params.object_id)\n      .await?;\n    self\n      .cache\n      .insert_encode_collab_to_disk(&workspace_id, uid, params)\n      .await?;\n    Ok(())\n  }\n\n  async fn upsert_collab_background(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n  ) -> AppResult<()> {\n    self\n      .check_or_update_permission(uid, &workspace_id, &params.object_id)\n      .await?;\n    trace!(\n      \"Queue insert collab:{}:{}\",\n      params.object_id,\n      params.collab_type\n    );\n\n    if let Err(err) = validate_encode_collab(\n      &params.object_id,\n      &params.encoded_collab_v1,\n      &params.collab_type,\n    )\n    .await\n    .map_err(|err| AppError::NoRequiredData(err.to_string()))\n    {\n      return Err(AppError::NoRequiredData(format!(\n        \"Invalid collab doc state detected for workspace_id: {}, uid: {}, object_id: {} collab_type:{}. Error details: {}\",\n        workspace_id, uid, params.object_id, params.collab_type, err\n      )));\n    }\n\n    let pending = PendingCollabWrite::new(workspace_id, *uid, params);\n    if let Err(e) = self.queue.send(pending).await {\n      error!(\"Failed to queue insert collab doc state: {}\", e);\n    }\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip(self, params), oid = %params.oid, ty = %params.collab_type, err)]\n  #[allow(clippy::blocks_in_conditions)]\n  async fn upsert_new_collab_with_transaction(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params: CollabParams,\n    transaction: &mut Transaction<'_, sqlx::Postgres>,\n    action_description: &str,\n  ) -> AppResult<()> {\n    params.validate()?;\n    self\n      .check_write_workspace_permission(&workspace_id, uid)\n      .await?;\n    self\n      .access_control\n      .update_access_level_policy(uid, &params.object_id, AFAccessLevel::FullAccess)\n      .await?;\n\n    match tokio::time::timeout(\n      Duration::from_secs(30),\n      self\n        .cache\n        .insert_encode_collab_data(&workspace_id, uid, params, transaction),\n    )\n    .await\n    {\n      Ok(Ok(())) => Ok(()),\n      Ok(Err(err)) => Err(err),\n      Err(_) => {\n        error!(\n          \"Timeout waiting for action completed: {}\",\n          action_description\n        );\n        Err(AppError::RequestTimeout(action_description.to_string()))\n      },\n    }\n  }\n\n  async fn batch_insert_new_collab(\n    &self,\n    workspace_id: Uuid,\n    uid: &i64,\n    params_list: Vec<CollabParams>,\n  ) -> AppResult<()> {\n    self\n      .check_write_workspace_permission(&workspace_id, uid)\n      .await?;\n\n    for params in &params_list {\n      self\n        .access_control\n        .update_access_level_policy(uid, &params.object_id, AFAccessLevel::FullAccess)\n        .await?;\n    }\n\n    match tokio::time::timeout(\n      Duration::from_secs(60),\n      self.batch_insert_collabs(workspace_id, uid, params_list),\n    )\n    .await\n    {\n      Ok(result) => result,\n      Err(_) => {\n        error!(\"Timeout waiting for action completed\",);\n        Err(AppError::RequestTimeout(\"\".to_string()))\n      },\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn get_full_encode_collab(\n    &self,\n    origin: GetCollabOrigin,\n    workspace_id: &Uuid,\n    object_id: &Uuid,\n    collab_type: CollabType,\n  ) -> AppResult<TimestampedEncodedCollab> {\n    if let GetCollabOrigin::User { uid } = origin {\n      // Check if the user has enough permissions to access the collab\n      trace!(\n        \"enforce read collab for user: {}, object_id: {}\",\n        uid,\n        object_id\n      );\n      let collab_exists = self.cache.is_exist(workspace_id, object_id).await?;\n      if collab_exists {\n        self\n          .access_control\n          .enforce_action(workspace_id, &uid, object_id, Action::Read)\n          .await?;\n      }\n    }\n\n    self\n      .cache\n      .get_full_collab(\n        workspace_id,\n        QueryCollab::new(*object_id, collab_type),\n        None,\n        EncoderVersion::V1,\n      )\n      .await\n  }\n\n  async fn batch_get_collab(\n    &self,\n    _uid: &i64,\n    workspace_id: Uuid,\n    queries: Vec<QueryCollab>,\n  ) -> HashMap<Uuid, QueryCollabResult> {\n    if queries.is_empty() {\n      return HashMap::new();\n    }\n    self\n      .cache\n      .batch_get_full_collab(&workspace_id, queries, None, EncoderVersion::V1)\n      .await\n  }\n\n  async fn delete_collab(&self, workspace_id: &Uuid, uid: &i64, object_id: &Uuid) -> AppResult<()> {\n    self\n      .access_control\n      .enforce_access_level(workspace_id, uid, object_id, AFAccessLevel::FullAccess)\n      .await?;\n    self.cache.delete_collab(workspace_id, object_id).await?;\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  fn mark_as_editing(&self, oid: Uuid) {\n    self.cache.mark_as_dirty(oid, MillisSeconds::now());\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/mod.rs",
    "content": "pub mod cache;\npub mod collab_manager;\npub mod collab_store;\npub mod snapshot_scheduler;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/collab/snapshot_scheduler.rs",
    "content": "use crate::ws2::CollabManager;\nuse appflowy_proto::Rid;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse tokio::sync::mpsc::error::TryRecvError;\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tokio::task::JoinSet;\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub struct SnapshotScheduler {\n  schedule_queue: UnboundedSender<(Uuid, Rid)>,\n}\n\nimpl SnapshotScheduler {\n  /// We limit number of concurrently running snapshot tasks to avoid overwhelming the system.\n  const CONCURRENCY_LIMIT: usize = 10; // only 10 workspaces at a time\n  pub fn new(collab_store: Arc<CollabManager>) -> Self {\n    let (schedule_queue, receiver) = tokio::sync::mpsc::unbounded_channel::<(Uuid, Rid)>();\n    tokio::spawn(Self::snapshot_task(receiver, collab_store));\n    Self { schedule_queue }\n  }\n\n  pub fn schedule_snapshot(&self, workspace_id: Uuid, last_message_id: Rid) {\n    // free to ignore errors here, as we only drop receiver when shutting down the server\n    let _ = self.schedule_queue.send((workspace_id, last_message_id));\n  }\n\n  async fn get_workspaces(\n    receiver: &mut UnboundedReceiver<(Uuid, Rid)>,\n  ) -> Option<HashMap<Uuid, Rid>> {\n    let mut workspaces: HashMap<Uuid, Rid> = HashMap::new();\n    let next = receiver.recv().await?;\n    workspaces.insert(next.0, next.1);\n\n    // Try to receive all remaining messages without blocking\n    loop {\n      match receiver.try_recv() {\n        Ok((workspace_id, last_message_id)) => {\n          let e = workspaces.entry(workspace_id).or_default();\n          *e = (*e).max(last_message_id);\n        },\n        Err(TryRecvError::Disconnected) if workspaces.is_empty() => return None,\n        Err(TryRecvError::Disconnected) => return Some(workspaces), // channel is closed, return what we have\n        Err(TryRecvError::Empty) => break,                          // we emptied the queue\n      }\n    }\n    Some(workspaces)\n  }\n\n  async fn drain_snapshot_tasks(tasks: &mut JoinSet<()>) {\n    while let Some(result) = tasks.join_next().await {\n      if let Err(err) = result {\n        tracing::error!(\"Snapshot task failed: {}\", err);\n      }\n    }\n  }\n\n  async fn snapshot_task(\n    mut receiver: UnboundedReceiver<(Uuid, Rid)>,\n    collab_store: Arc<CollabManager>,\n  ) {\n    while let Some(workspaces) = Self::get_workspaces(&mut receiver).await {\n      tracing::trace!(\"snapshotting {} workspaces\", workspaces.len());\n      let mut tasks = JoinSet::new();\n      let mut i = 0;\n      for (workspace_id, last_message_id) in workspaces {\n        let collab_store = collab_store.clone();\n        tasks.spawn(async move {\n          if let Err(err) = collab_store\n            .snapshot_workspace(workspace_id, last_message_id)\n            .await\n          {\n            tracing::error!(\n              \"Failed to snapshot workspace {} at message id {}: {}\",\n              workspace_id,\n              last_message_id,\n              err\n            );\n          }\n        });\n        i += 1;\n        if i >= Self::CONCURRENCY_LIMIT {\n          Self::drain_snapshot_tasks(&mut tasks).await;\n          i = 0;\n        }\n      }\n\n      if i > 0 {\n        Self::drain_snapshot_tasks(&mut tasks).await;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/compression.rs",
    "content": "use app_error::AppError;\nuse brotli::Decompressor;\nuse std::io::Read;\n\npub const X_COMPRESSION_TYPE: &str = \"X-Compression-Type\";\n\npub const X_COMPRESSION_BUFFER_SIZE: &str = \"X-Compression-Buffer-Size\";\npub enum CompressionType {\n  Brotli { buffer_size: usize },\n}\n\npub async fn decompress(data: Vec<u8>, buffer_size: usize) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || {\n    let mut decompressor = Decompressor::new(&*data, buffer_size);\n    let mut decompressed_data = Vec::new();\n    decompressor\n      .read_to_end(&mut decompressed_data)\n      .map_err(|err| {\n        AppError::InvalidRequest(format!(\"Failed to decompress data:{} {}\", data.len(), err))\n      })?;\n    Ok(decompressed_data)\n  })\n  .await\n  .map_err(AppError::from)?\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/config.rs",
    "content": "use anyhow::Context;\nuse secrecy::Secret;\nuse semver::Version;\nuse serde::Deserialize;\nuse sqlx::postgres::{PgConnectOptions, PgSslMode};\nuse std::env::VarError;\nuse std::fmt::Display;\nuse std::str::FromStr;\n\n#[derive(Clone, Debug)]\npub struct Config {\n  pub app_env: Environment,\n  pub application: ApplicationSetting,\n  pub websocket: WebsocketSetting,\n  pub db_settings: DatabaseSetting,\n  pub gotrue: GoTrueSetting,\n  pub collab: CollabSetting,\n  pub redis_uri: Secret<String>,\n  pub redis_worker_count: usize,\n  pub ai: AISettings,\n  pub s3: S3Setting,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct S3Setting {\n  pub create_bucket: bool,\n  pub use_minio: bool,\n  pub minio_url: String,\n  pub access_key: String,\n  pub secret_key: Secret<String>,\n  pub bucket: String,\n  pub region: String,\n  pub presigned_url_endpoint: Option<String>,\n}\n\n#[derive(Clone, Debug)]\npub struct ApplicationSetting {\n  pub port: u16,\n  pub host: String,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub enum Environment {\n  Local,\n  Production,\n}\n\nimpl Environment {\n  pub fn as_str(&self) -> &'static str {\n    match self {\n      Environment::Local => \"local\",\n      Environment::Production => \"production\",\n    }\n  }\n}\n\nimpl FromStr for Environment {\n  type Err = anyhow::Error;\n\n  fn from_str(s: &str) -> Result<Self, Self::Err> {\n    match s.to_lowercase().as_str() {\n      \"local\" => Ok(Self::Local),\n      \"production\" => Ok(Self::Production),\n      other => anyhow::bail!(\n        \"{} is not a supported environment. Use either `local` or `production`.\",\n        other\n      ),\n    }\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct AISettings {\n  pub port: u16,\n  pub host: String,\n}\n\nimpl AISettings {\n  pub fn url(&self) -> String {\n    format!(\"http://{}:{}\", self.host, self.port)\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct WebsocketSetting {\n  pub heartbeat_interval: u8,\n  pub client_timeout: u8,\n  pub min_client_version: Version,\n}\n\n#[derive(Clone, Debug)]\npub struct DatabaseSetting {\n  pub pg_conn_opts: PgConnectOptions,\n  pub require_ssl: bool,\n  /// PostgreSQL has a maximum of 115 connections to the database, 15 connections are reserved to\n  /// the super user to maintain the integrity of the PostgreSQL database, and 100 PostgreSQL\n  /// connections are reserved for system applications.\n  /// When we exceed the limit of the database connection, then it shows an error message.\n  pub max_connections: u32,\n}\n\nimpl Display for DatabaseSetting {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"DatabaseSetting {{ pg_conn_opts: {:?}, require_ssl: {}, max_connections: {} }}\",\n      self.pg_conn_opts, self.require_ssl, self.max_connections\n    )\n  }\n}\n\nimpl DatabaseSetting {\n  pub fn pg_connect_options(&self) -> PgConnectOptions {\n    let ssl_mode = if self.require_ssl {\n      PgSslMode::Require\n    } else {\n      PgSslMode::Prefer\n    };\n    let options = self.pg_conn_opts.clone();\n    options.ssl_mode(ssl_mode)\n  }\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub struct GoTrueSetting {\n  pub jwt_secret: Secret<String>,\n}\n\n#[derive(Clone, Debug)]\npub struct CollabSetting {\n  pub group_persistence_interval_secs: u64,\n  pub group_prune_grace_period_secs: u64,\n  pub edit_state_max_count: u32,\n  pub edit_state_max_secs: i64,\n  pub s3_collab_threshold: u64,\n}\n\npub fn get_env_var(key: &str, default: &str) -> String {\n  std::env::var(key).unwrap_or_else(|err| {\n    match err {\n      VarError::NotPresent => {\n        tracing::info!(\"using default environment variable {}:{}\", key, default)\n      },\n      VarError::NotUnicode(_) => {\n        tracing::error!(\n          \"{} is not a valid UTF-8 string, use default value:{}\",\n          key,\n          default\n        );\n      },\n    }\n    default.to_owned()\n  })\n}\n\npub fn get_configuration() -> Result<Config, anyhow::Error> {\n  let config = Config {\n    app_env: get_env_var(\"APPFLOWY_ENVIRONMENT\", \"local\")\n      .parse()\n      .context(\"fail to get APPFLOWY_ENVIRONMENT\")?,\n    application: ApplicationSetting {\n      port: get_env_var(\"APPFLOWY_COLLAB_SERVICE_PORT\", \"8001\").parse()?,\n      host: get_env_var(\"APPFLOWY_COLLAB_SERVICE_HOST\", \"0.0.0.0\"),\n    },\n    websocket: WebsocketSetting {\n      heartbeat_interval: get_env_var(\"APPFLOWY_WEBSOCKET_HEARTBEAT_INTERVAL\", \"6\").parse()?,\n      client_timeout: get_env_var(\"APPFLOWY_WEBSOCKET_CLIENT_TIMEOUT\", \"60\").parse()?,\n      min_client_version: get_env_var(\"APPFLOWY_WEBSOCKET_CLIENT_MIN_VERSION\", \"0.5.0\").parse()?,\n    },\n    db_settings: DatabaseSetting {\n      pg_conn_opts: PgConnectOptions::from_str(&get_env_var(\n        \"APPFLOWY_DATABASE_URL\",\n        \"postgres://postgres:password@localhost:5432/postgres\",\n      ))?,\n      require_ssl: get_env_var(\"APPFLOWY_DATABASE_REQUIRE_SSL\", \"false\")\n        .parse()\n        .context(\"fail to get APPFLOWY_DATABASE_REQUIRE_SSL\")?,\n      max_connections: get_env_var(\"APPFLOWY_DATABASE_MAX_CONNECTIONS\", \"40\")\n        .parse()\n        .context(\"fail to get APPFLOWY_DATABASE_MAX_CONNECTIONS\")?,\n    },\n    s3: S3Setting {\n      create_bucket: get_env_var(\"APPFLOWY_S3_CREATE_BUCKET\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_S3_CREATE_BUCKET\")?,\n      use_minio: get_env_var(\"APPFLOWY_S3_USE_MINIO\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_S3_USE_MINIO\")?,\n      minio_url: get_env_var(\"APPFLOWY_S3_MINIO_URL\", \"http://localhost:9000\"),\n      access_key: get_env_var(\"APPFLOWY_S3_ACCESS_KEY\", \"minioadmin\"),\n      secret_key: get_env_var(\"APPFLOWY_S3_SECRET_KEY\", \"minioadmin\").into(),\n      bucket: get_env_var(\"APPFLOWY_S3_BUCKET\", \"appflowy\"),\n      region: get_env_var(\"APPFLOWY_S3_REGION\", \"\"),\n      presigned_url_endpoint: None,\n    },\n    gotrue: GoTrueSetting {\n      jwt_secret: get_env_var(\"APPFLOWY_GOTRUE_JWT_SECRET\", \"hello456\").into(),\n    },\n    collab: CollabSetting {\n      group_persistence_interval_secs: get_env_var(\n        \"APPFLOWY_COLLAB_GROUP_PERSISTENCE_INTERVAL\",\n        \"60\",\n      )\n      .parse()?,\n      group_prune_grace_period_secs: get_env_var(\"APPFLOWY_COLLAB_GROUP_GRACE_PERIOD_SECS\", \"60\")\n        .parse()?,\n      edit_state_max_count: get_env_var(\"APPFLOWY_COLLAB_EDIT_STATE_MAX_COUNT\", \"100\").parse()?,\n      edit_state_max_secs: get_env_var(\"APPFLOWY_COLLAB_EDIT_STATE_MAX_SECS\", \"60\").parse()?,\n      s3_collab_threshold: get_env_var(\"APPFLOWY_COLLAB_S3_THRESHOLD\", \"8000\").parse()?,\n    },\n    redis_uri: get_env_var(\"APPFLOWY_REDIS_URI\", \"redis://localhost:6379\").into(),\n    redis_worker_count: get_env_var(\"APPFLOWY_REDIS_WORKERS\", \"60\").parse()?,\n    ai: AISettings {\n      port: get_env_var(\"AI_SERVER_PORT\", \"5001\").parse()?,\n      host: get_env_var(\"AI_SERVER_HOST\", \"localhost\"),\n    },\n  };\n  Ok(config)\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/connect_state.rs",
    "content": "use collab_rt_entity::user::{RealtimeUser, UserDevice};\nuse collab_rt_entity::{RealtimeMessage, SystemMessage};\nuse dashmap::DashMap;\n\nuse crate::client::client_msg_router::ClientMessageRouter;\nuse dashmap::mapref::entry::Entry;\nuse dashmap::try_result::TryResult;\nuse std::sync::Arc;\nuse tracing::{info, instrument, trace, warn};\n\n#[derive(Clone, Default)]\npub struct ConnectState {\n  user_by_device: Arc<DashMap<UserDevice, RealtimeUser>>,\n  /// Maintains a record of all client streams. A client stream associated with a user may be terminated for the following reasons:\n  /// 1. User disconnection.\n  /// 2. Server closes the connection due to a ping/pong timeout.\n  pub(crate) client_message_routers: Arc<DashMap<RealtimeUser, ClientMessageRouter>>,\n}\n\nimpl ConnectState {\n  pub fn new() -> Self {\n    Self::default()\n  }\n\n  pub fn get_user_by_device(&self, device: &UserDevice) -> Option<RealtimeUser> {\n    let result = self.user_by_device.try_get(device);\n    match result {\n      TryResult::Present(entry) => Some(entry.value().clone()),\n      TryResult::Absent => None,\n      TryResult::Locked => None,\n    }\n  }\n\n  /// Handles a new user connection, updating the connection state accordingly.\n  ///\n  /// This function checks if there is already a connection from the same user device. If an existing\n  /// connection is found and the new connection is more recent (`connect_at` is greater), the old connection\n  /// is replaced with the new one. This process involves:\n  ///\n  /// - Removing the old user's client stream, if present, and sending a `DuplicateConnection` system message.\n  /// - Inserting the new user connection into the `user_by_device` and `client_stream_by_user` maps.\n  ///\n  #[instrument(level = \"trace\", skip_all)]\n  pub fn handle_user_connect(\n    &self,\n    new_user: RealtimeUser,\n    client_message_router: ClientMessageRouter,\n  ) -> Option<RealtimeUser> {\n    let user_device = UserDevice::from(&new_user);\n    let entry = self.user_by_device.try_entry(user_device);\n    match entry {\n      Some(Entry::Occupied(mut e)) => {\n        if e.get().connect_at <= new_user.connect_at {\n          let old_user = e.insert(new_user.clone());\n          trace!(\"[realtime]: new connection replaces old => {}\", new_user);\n          if let Some((_, old_stream)) = self.client_message_routers.remove(&old_user) {\n            info!(\"Removing old stream for same user and device: {}\", old_user);\n\n            old_stream\n              .sink\n              .do_send(RealtimeMessage::System(SystemMessage::DuplicateConnection));\n          }\n          self\n            .client_message_routers\n            .insert(new_user, client_message_router);\n          Some(old_user)\n        } else {\n          None\n        }\n      },\n      Some(Entry::Vacant(e)) => {\n        trace!(\"[realtime]: new connection => {}\", new_user);\n        e.insert(new_user.clone());\n        self\n          .client_message_routers\n          .insert(new_user, client_message_router);\n        None\n      },\n      None => {\n        warn!(\"[realtime] failed to insert user connection: {}\", new_user);\n        None\n      },\n    }\n  }\n\n  /// Handles the disconnection of a user from the system.\n  ///\n  /// remove a user based on their device and session ID. If the session ID of the disconnecting user matches\n  /// the session ID stored in the system for that device, the user is removed. Additionally, it also\n  /// attempts to remove the associated client stream for the disconnecting user.\n  ///\n  pub fn handle_user_disconnect(\n    &self,\n    disconnect_user: &RealtimeUser,\n  ) -> Option<(UserDevice, RealtimeUser)> {\n    let user_device = UserDevice::from(disconnect_user);\n    let was_removed = self\n      .user_by_device\n      .remove_if(&user_device, |_, existing_user| {\n        existing_user.session_id == disconnect_user.session_id\n      });\n\n    if was_removed.is_some()\n      && self\n        .client_message_routers\n        .remove(disconnect_user)\n        .is_some()\n    {\n      info!(\"remove client stream: {}\", &disconnect_user);\n    }\n\n    was_removed\n  }\n\n  pub fn number_of_connected_users(&self) -> usize {\n    self.user_by_device.len()\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::client::client_msg_router::ClientMessageRouter;\n  use crate::connect_state::ConnectState;\n  use crate::RealtimeClientWebsocketSink;\n  use collab_rt_entity::user::{RealtimeUser, UserDevice};\n  use collab_rt_entity::RealtimeMessage;\n  use std::time::Duration;\n  use tokio::time::sleep;\n\n  struct MockSink;\n\n  impl RealtimeClientWebsocketSink for MockSink {\n    fn do_send(&self, _message: RealtimeMessage) {}\n  }\n\n  fn mock_user(uid: i64, device_id: &str, connect_at: i64) -> RealtimeUser {\n    RealtimeUser::new(\n      uid,\n      device_id.to_string(),\n      uuid::Uuid::new_v4().to_string(),\n      connect_at,\n      \"0.5.8\".to_string(),\n    )\n  }\n\n  fn mock_stream() -> ClientMessageRouter {\n    ClientMessageRouter::new(MockSink)\n  }\n\n  #[tokio::test]\n  async fn same_user_different_device_connect_test() {\n    let connect_state = ConnectState::new();\n    let user_device_a = mock_user(1, \"device_a\", 1);\n    let user_device_b = mock_user(1, \"device_b\", 1);\n    connect_state.handle_user_connect(user_device_a, mock_stream());\n    connect_state.handle_user_connect(user_device_b, mock_stream());\n\n    assert_eq!(connect_state.user_by_device.len(), 2);\n  }\n\n  #[tokio::test]\n  async fn same_user_same_device_connect_test() {\n    let connect_state = ConnectState::new();\n    let user_device_a = mock_user(1, \"device_a\", 1);\n    let user_device_b = mock_user(1, \"device_a\", 1);\n    connect_state.handle_user_connect(user_device_a, mock_stream());\n    connect_state.handle_user_connect(user_device_b.clone(), mock_stream());\n\n    assert_eq!(connect_state.user_by_device.len(), 1);\n    let user = connect_state\n      .get_user_by_device(&UserDevice::from(&user_device_b))\n      .unwrap();\n    assert_eq!(user, user_device_b);\n  }\n\n  #[tokio::test]\n  async fn multiple_same_devices_connect_test() {\n    let connect_state = ConnectState::new();\n    let mut handles = vec![];\n    for i in 0..1000 {\n      let cloned_connect_state = connect_state.clone();\n      let handle = tokio::spawn(async move {\n        let random_seconds = rand::random::<u64>() % 500;\n        sleep(Duration::from_millis(random_seconds)).await;\n        let user = mock_user(1, \"device_a\", i);\n        cloned_connect_state.handle_user_connect(user, mock_stream());\n      });\n      handles.push(handle);\n    }\n\n    let _ = futures::future::join_all(handles).await;\n    let user = connect_state\n      .get_user_by_device(&UserDevice::new(\"device_a\", 1))\n      .unwrap();\n    assert_eq!(connect_state.user_by_device.len(), 1);\n    assert_eq!(connect_state.client_message_routers.len(), 1);\n    assert_eq!(user.connect_at, 999);\n  }\n\n  #[tokio::test]\n  async fn multiple_same_devices_connect_disconnect_test() {\n    let connect_state = ConnectState::new();\n    let mut handles = vec![];\n    for i in 0..2000 {\n      let should_disconnect = i % 2 == 0;\n      let cloned_connect_state = connect_state.clone();\n      let handle = tokio::spawn(async move {\n        let random_seconds = rand::random::<u64>() % 500;\n        sleep(Duration::from_millis(random_seconds)).await;\n        let user = mock_user(1, \"device_a\", i);\n\n        if should_disconnect {\n          cloned_connect_state.handle_user_disconnect(&user);\n        } else {\n          cloned_connect_state.handle_user_connect(user, mock_stream());\n        }\n      });\n      handles.push(handle);\n    }\n\n    let _ = futures::future::join_all(handles).await;\n    let user = connect_state\n      .get_user_by_device(&UserDevice::new(\"device_a\", 1))\n      .unwrap();\n\n    assert_eq!(connect_state.user_by_device.len(), 1);\n    assert_eq!(connect_state.client_message_routers.len(), 1);\n    assert_eq!(user.connect_at, 1999);\n  }\n\n  #[tokio::test]\n  async fn multiple_devices_connect_test() {\n    let user_a = vec![\n      mock_user(1, \"device_a\", 1),\n      mock_user(1, \"device_b\", 2),\n      mock_user(1, \"device_c\", 1),\n      mock_user(1, \"device_d\", 1),\n    ];\n\n    let user_b = vec![\n      mock_user(2, \"device_a\", 1),\n      // device_b starts two connections, last connection should be kept.\n      mock_user(2, \"device_b\", 1),\n      mock_user(2, \"device_b\", 2),\n      mock_user(2, \"device_a\", 1),\n    ];\n\n    let connect_state = ConnectState::new();\n\n    let (tx, rx_1) = tokio::sync::oneshot::channel();\n    let cloned_connect_state = connect_state.clone();\n    tokio::spawn(async move {\n      for user in user_a {\n        cloned_connect_state.handle_user_connect(user, mock_stream());\n      }\n      tx.send(()).unwrap();\n    });\n\n    let (tx, rx_2) = tokio::sync::oneshot::channel();\n    let cloned_connect_state = connect_state.clone();\n    let clone_user_b = user_b.clone();\n    tokio::spawn(async move {\n      for user in clone_user_b {\n        cloned_connect_state.handle_user_connect(user, mock_stream());\n      }\n      tx.send(()).unwrap();\n    });\n\n    let _ = futures::future::join(rx_1, rx_2).await;\n    assert_eq!(connect_state.user_by_device.len(), 6);\n\n    // device_b with connect_at 2 should be kept.\n    let user = connect_state\n      .get_user_by_device(&UserDevice::from(&user_b[2]))\n      .unwrap();\n    assert_eq!(user.connect_at, 2);\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/error.rs",
    "content": "use collab::error::CollabError;\nuse collab_stream::error::StreamError;\nuse std::fmt::Display;\n\n#[derive(Debug, thiserror::Error)]\npub enum RealtimeError {\n  #[error(transparent)]\n  YSync(#[from] collab_rt_protocol::RTProtocolError),\n\n  #[error(transparent)]\n  YAwareness(#[from] collab::core::awareness::Error),\n\n  #[error(\"failed to deserialize message: {0}\")]\n  YrsDecodingError(#[from] yrs::encoding::read::Error),\n\n  #[error(transparent)]\n  SerdeError(#[from] serde_json::Error),\n\n  #[error(transparent)]\n  TokioTask(#[from] tokio::task::JoinError),\n\n  #[error(transparent)]\n  IO(#[from] std::io::Error),\n\n  #[error(\"Unexpected data: {0}\")]\n  UnexpectedData(&'static str),\n\n  #[error(\"Expected init sync message, but received: {0}\")]\n  ExpectInitSync(String),\n\n  #[error(transparent)]\n  CollabError(#[from] CollabError),\n\n  #[error(\"Received message from client:{0}, but the client does not have sufficient permissions to write\")]\n  NotEnoughPermissionToWrite(i64),\n\n  #[error(\"Client:{0} does not have enough permission to read\")]\n  NotEnoughPermissionToRead(i64),\n\n  #[error(\"{0}\")]\n  UserNotFound(String),\n\n  #[error(\"group is not exist: {0}\")]\n  GroupNotFound(String),\n\n  #[error(\"Create group failed:{0}\")]\n  CreateGroupFailed(CreateGroupFailedReason),\n\n  #[error(\"Lack of required collab data: {0}\")]\n  NoRequiredCollabData(String),\n\n  #[error(\"{0} send too many messages\")]\n  TooManyMessage(String),\n\n  #[error(\"Acquire lock timeout\")]\n  LockTimeout,\n\n  #[error(\"Internal failure: {0}\")]\n  Internal(#[from] anyhow::Error),\n\n  #[error(\"Collab redis stream error: {0}\")]\n  StreamError(#[from] StreamError),\n\n  #[error(\"Cannot create group: {0}\")]\n  CannotCreateGroup(String),\n\n  #[error(\"BinCodeCollab error: {0}\")]\n  BincodeEncode(String),\n\n  #[error(\"Failed to create snapshot: {0}\")]\n  CreateSnapshotFailed(String),\n\n  #[error(\"Failed to get latest snapshot: {0}\")]\n  GetLatestSnapshotFailed(String),\n\n  #[error(\"Collab Schema Error: {0}\")]\n  CollabSchemaError(String),\n\n  #[error(\"failed to obtain lease: {0}\")]\n  Lease(Box<dyn std::error::Error + Send + Sync>),\n\n  #[error(\"failed to send ws message: {0}\")]\n  SendWSMessageFailed(String),\n\n  #[error(\"failed to parse UUID: {0}\")]\n  Uuid(#[from] uuid::Error),\n}\n\n#[derive(Debug)]\npub enum CreateGroupFailedReason {\n  CollabWorkspaceIdNotMatch { expect: String, detail: String },\n  CannotGetCollabData,\n}\n\nimpl Display for CreateGroupFailedReason {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      CreateGroupFailedReason::CollabWorkspaceIdNotMatch { expect, detail } => {\n        write!(\n          f,\n          \"Collab workspace id not match: expect {}, detail: {}\",\n          expect, detail\n        )\n      },\n      CreateGroupFailedReason::CannotGetCollabData => {\n        write!(f, \"Cannot get collab data\")\n      },\n    }\n  }\n}\n\nimpl RealtimeError {\n  pub fn is_too_many_message(&self) -> bool {\n    matches!(self, RealtimeError::TooManyMessage(_))\n  }\n\n  pub fn is_lock_timeout(&self) -> bool {\n    matches!(self, RealtimeError::LockTimeout)\n  }\n  pub fn is_create_group_failed(&self) -> bool {\n    matches!(self, RealtimeError::CreateGroupFailed(_))\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/cmd.rs",
    "content": "use crate::client::client_msg_router::ClientMessageRouter;\nuse crate::error::RealtimeError;\nuse crate::group::manager::GroupManager;\nuse crate::group::null_sender::NullSender;\nuse async_stream::stream;\nuse bytes::Bytes;\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse dashmap::DashMap;\nuse futures_util::StreamExt;\nuse std::sync::Arc;\n\nuse collab_entity::CollabType;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::CollabAck;\nuse collab_rt_entity::{\n  AckCode, ClientCollabMessage, MessageByObjectId, ServerCollabMessage, SinkMessage, UpdateSync,\n};\nuse collab_rt_protocol::{Message, SyncMessage};\nuse dashmap::mapref::entry::Entry;\nuse tracing::{instrument, trace, warn};\nuse uuid::Uuid;\nuse yrs::updates::encoder::Encode;\nuse yrs::StateVector;\n\n/// Using [GroupCommand] to interact with the group\n/// - HandleClientCollabMessage: Handle the client message\n/// - EncodeCollab: Encode the collab\n/// - HandleServerCollabMessage: Handle the server message\npub enum GroupCommand {\n  HandleClientCollabMessage {\n    user: RealtimeUser,\n    object_id: Uuid,\n    collab_messages: Vec<ClientCollabMessage>,\n    ret: tokio::sync::oneshot::Sender<Result<(), RealtimeError>>,\n  },\n  HandleClientHttpUpdate {\n    user: RealtimeUser,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    update: Bytes,\n    collab_type: CollabType,\n    ret: tokio::sync::oneshot::Sender<Result<(), RealtimeError>>,\n  },\n  GenerateCollabEmbedding {\n    object_id: Uuid,\n  },\n  CalculateMissingUpdate {\n    object_id: Uuid,\n    state_vector: StateVector,\n    ret: tokio::sync::oneshot::Sender<Result<Vec<u8>, RealtimeError>>,\n  },\n}\n\npub type GroupCommandSender = tokio::sync::mpsc::Sender<GroupCommand>;\npub type GroupCommandReceiver = tokio::sync::mpsc::Receiver<GroupCommand>;\n\n/// Each group has a command runner to handle the group command. GroupCommandRunner is designed to run\n/// in tokio multi-thread runtime. It will receive the group command from the receiver and handle the\n/// command.\n///\npub struct GroupCommandRunner {\n  pub group_manager: Arc<GroupManager>,\n  pub msg_router_by_user: Arc<DashMap<RealtimeUser, ClientMessageRouter>>,\n  pub recv: Option<GroupCommandReceiver>,\n}\n\nimpl GroupCommandRunner {\n  pub async fn run(mut self, object_id: Uuid) {\n    let mut receiver = self.recv.take().expect(\"Only take once\");\n    let stream = stream! {\n      while let Some(msg) = receiver.recv().await {\n         yield msg;\n      }\n      trace!(\"Collab group:{} command runner is stopped\", object_id);\n    };\n    stream\n      .for_each(|command| async {\n        match command {\n          GroupCommand::HandleClientCollabMessage {\n            user,\n            object_id,\n            collab_messages,\n            ret,\n          } => {\n            let result = self\n              .handle_client_collab_message(user, object_id, collab_messages)\n              .await;\n            if let Err(err) = ret.send(result) {\n              warn!(\"Send handle client collab message result fail: {:?}\", err);\n            }\n          },\n          GroupCommand::HandleClientHttpUpdate {\n            user,\n            workspace_id,\n            object_id,\n            update,\n            collab_type,\n            ret,\n          } => {\n            let result = self\n              .handle_client_posted_http_update(user, workspace_id, object_id, collab_type, update)\n              .await;\n            if let Err(err) = ret.send(result) {\n              warn!(\"Send handle client update message result fail: {:?}\", err);\n            }\n          },\n          GroupCommand::GenerateCollabEmbedding { object_id } => {\n            if let Some(group) = self.group_manager.get_group(&object_id).await {\n              match group.generate_embeddings().await {\n                Ok(_) => trace!(\"successfully created embeddings for {}\", object_id),\n                Err(err) => trace!(\"failed to create embeddings for {}: {}\", object_id, err),\n              }\n            }\n          },\n          GroupCommand::CalculateMissingUpdate {\n            object_id,\n            state_vector,\n            ret,\n          } => {\n            let group = self.group_manager.get_group(&object_id).await;\n            match group {\n              None => {\n                let _ = ret.send(Err(RealtimeError::GroupNotFound(object_id.to_string())));\n              },\n              Some(group) => {\n                let result = group.calculate_missing_update(state_vector).await;\n                let _ = ret.send(result);\n              },\n            }\n          },\n        }\n      })\n      .await;\n  }\n\n  /// Processes a client message with the following logic:\n  /// 1. Verifies client connection to the websocket server.\n  /// 2. Processes [CollabMessage] messages as follows:\n  ///    2.1 For 'init sync' messages:\n  ///      - If the group exists: Removes the old subscription and re-subscribes the user.\n  ///      - If the group does not exist: Creates a new group.\n  ///      In both cases, the message is then sent to the group for synchronization according to [CollabSyncProtocol],\n  ///      which includes broadcasting to all connected clients.\n  ///    2.2 For non-'init sync' messages:\n  ///      - If the group exists: The message is sent to the group for synchronization as per [CollabSyncProtocol].\n  ///      - If the group does not exist: The client is prompted to send an 'init sync' message first.\n  #[instrument(level = \"trace\", skip_all)]\n  async fn handle_client_collab_message(\n    &self,\n    user: RealtimeUser,\n    object_id: Uuid,\n    messages: Vec<ClientCollabMessage>,\n  ) -> Result<(), RealtimeError> {\n    if messages.is_empty() {\n      warn!(\"Unexpected empty collab messages sent from client\");\n      return Ok(());\n    }\n    // 1.Check the client is connected with the websocket server.\n    if self.msg_router_by_user.get(&user).is_none() {\n      // 1. **Client Not Connected**: This case occurs when there is an attempt to interact with a\n      // WebSocket server, but the client has not established a connection with the server. The action\n      // or message intended for the server cannot proceed because there is no active connection.\n      // 2. **Duplicate Connections from the Same Device**: When a client from the same device attempts\n      // to establish a new WebSocket connection while a previous connection from that device already\n      // exists, the new connection will supersede and replace the old one.\n      trace!(\"The client stream: {} is not found, it should be created when the client is connected with this websocket server\", user);\n      return Ok(());\n    }\n\n    let is_group_exist = self.group_manager.contains_group(&object_id);\n    if is_group_exist {\n      // subscribe the user to the group. then the user will receive the changes from the group\n      let is_user_subscribed = self.group_manager.contains_user(&object_id, &user);\n      if !is_user_subscribed {\n        // safety: messages is not empty because we have checked it before\n        let first_message = messages.first().unwrap();\n        self\n          .subscribe_group_with_message(&user, first_message)\n          .await?;\n      }\n      forward_message_to_group(user, object_id, messages, &self.msg_router_by_user).await;\n    } else {\n      let first_message = messages.first().unwrap();\n      // If there is no existing group for the given object_id and the message is an 'init message',\n      // then create a new group and add the user as a subscriber to this group.\n      if first_message.is_client_init_sync() {\n        self.create_group_with_message(&user, first_message).await?;\n        self\n          .subscribe_group_with_message(&user, first_message)\n          .await?;\n        forward_message_to_group(user, object_id, messages, &self.msg_router_by_user).await;\n      } else if let Some(entry) = self.msg_router_by_user.get(&user) {\n        warn!(\n          \"The group:{} is not found, the client:{} should send the init message first\",\n          first_message.object_id(),\n          user\n        );\n        let origin = first_message.origin().clone();\n        let msg_id = first_message.msg_id();\n        let object_id = first_message.object_id().to_string();\n\n        // when the group with given id is not found and the the first message is not init sync.\n        // Return AckCode::CannotApplyUpdate to the client and then client will send the init sync message.\n        let ack =\n          CollabAck::new(origin, object_id, msg_id, 0).with_code(AckCode::CannotApplyUpdate);\n        entry\n          .value()\n          .send_message(ServerCollabMessage::ClientAck(ack).into())\n          .await;\n      }\n    }\n    Ok(())\n  }\n\n  /// This functions will be called when client post update via http requset\n  #[instrument(level = \"trace\", skip_all)]\n  async fn handle_client_posted_http_update(\n    &self,\n    user: RealtimeUser,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: collab_entity::CollabType,\n    update: Bytes,\n  ) -> Result<(), RealtimeError> {\n    let origin = CollabOrigin::Client(CollabClient {\n      uid: user.uid,\n      device_id: user.device_id.clone(),\n    });\n\n    // Create message router for user if it's not exist\n    self\n      .msg_router_by_user\n      .entry(user.clone())\n      .or_insert_with(|| {\n        trace!(\"create a new client message router for user:{}\", user);\n        ClientMessageRouter::new(NullSender::<()>::default())\n      });\n\n    // Create group if it's not exist\n    let is_group_exist = self.group_manager.contains_group(&object_id);\n    if !is_group_exist {\n      trace!(\"The group:{} is not found, create a new group\", object_id);\n      self\n        .create_group(&user, workspace_id, object_id, collab_type)\n        .await?;\n    }\n\n    // Only subscribe when the user is not subscribed to the group\n    if !self.group_manager.contains_user(&object_id, &user) {\n      self.subscribe_group(&user, object_id, &origin).await?;\n    }\n    if let Entry::Occupied(client_stream) = self.msg_router_by_user.entry(user) {\n      let payload = Message::Sync(SyncMessage::Update(update.to_vec())).encode_v1();\n      let msg = ClientCollabMessage::ClientUpdateSync {\n        data: UpdateSync {\n          origin,\n          object_id: object_id.to_string(),\n          msg_id: chrono::Utc::now().timestamp_millis() as u64,\n          payload: payload.into(),\n        },\n      };\n      let message = MessageByObjectId::new_with_message(object_id.to_string(), vec![msg]);\n      if client_stream.get().stream_tx.send(message).is_err() {\n        client_stream.remove();\n      }\n    }\n    Ok(())\n  }\n\n  async fn subscribe_group_with_message(\n    &self,\n    user: &RealtimeUser,\n    collab_message: &ClientCollabMessage,\n  ) -> Result<(), RealtimeError> {\n    let object_id = Uuid::parse_str(collab_message.object_id())?;\n    let message_origin = collab_message.origin();\n    self.subscribe_group(user, object_id, message_origin).await\n  }\n\n  async fn subscribe_group(\n    &self,\n    user: &RealtimeUser,\n    object_id: Uuid,\n    collab_origin: &CollabOrigin,\n  ) -> Result<(), RealtimeError> {\n    match self.msg_router_by_user.get_mut(user) {\n      None => {\n        warn!(\"The client stream: {} is not found\", user);\n        Ok(())\n      },\n      Some(mut client_msg_router) => {\n        self\n          .group_manager\n          .subscribe_group(\n            user,\n            object_id,\n            collab_origin,\n            client_msg_router.value_mut(),\n          )\n          .await\n      },\n    }\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  async fn create_group_with_message(\n    &self,\n    user: &RealtimeUser,\n    collab_message: &ClientCollabMessage,\n  ) -> Result<(), RealtimeError> {\n    let object_id = Uuid::parse_str(collab_message.object_id())?;\n    match collab_message {\n      ClientCollabMessage::ClientInitSync { data, .. } => {\n        let workspace_id = Uuid::parse_str(&data.workspace_id)?;\n        self\n          .create_group(user, workspace_id, object_id, data.collab_type)\n          .await?;\n        Ok(())\n      },\n      _ => Err(RealtimeError::ExpectInitSync(collab_message.to_string())),\n    }\n  }\n\n  #[instrument(level = \"debug\", skip_all)]\n  async fn create_group(\n    &self,\n    user: &RealtimeUser,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: collab_entity::CollabType,\n  ) -> Result<(), RealtimeError> {\n    self\n      .group_manager\n      .create_group(user, workspace_id, object_id, collab_type)\n      .await?;\n\n    Ok(())\n  }\n}\n\n/// Forward the message to the group.\n/// When the group receives the message, it will broadcast the message to all the users in the group.\n#[inline]\npub async fn forward_message_to_group(\n  user: RealtimeUser,\n  object_id: Uuid,\n  collab_messages: Vec<ClientCollabMessage>,\n  client_msg_router: &Arc<DashMap<RealtimeUser, ClientMessageRouter>>,\n) {\n  if let Entry::Occupied(client_stream) = client_msg_router.entry(user.clone()) {\n    let message = MessageByObjectId::new_with_message(object_id.to_string(), collab_messages);\n    let err = client_stream.get().stream_tx.send(message);\n    if let Err(err) = err {\n      warn!(\"Send user:{} message to group:{}\", user.uid, err);\n      client_stream.remove();\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/group_init.rs",
    "content": "use crate::error::RealtimeError;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse arc_swap::ArcSwap;\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab::lock::RwLock;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::{\n  AckCode, AwarenessSync, BroadcastSync, CollabAck, MessageByObjectId, MsgId,\n};\nuse collab_rt_entity::{ClientCollabMessage, CollabMessage};\nuse collab_rt_protocol::{Message, MessageReader, RTProtocolError, SyncMessage};\nuse collab_stream::client::CollabRedisStream;\nuse collab_stream::collab_update_sink::CollabUpdateSink;\n\nuse crate::metrics::CollabRealtimeMetrics;\nuse appflowy_proto::Rid;\nuse bytes::Bytes;\nuse chrono::{DateTime, Utc};\nuse collab_document::document::DocumentBody;\nuse collab_stream::awareness_gossip::AwarenessUpdateSink;\nuse collab_stream::error::StreamError;\nuse collab_stream::model::{\n  AwarenessStreamUpdate, CollabStreamUpdate, MessageId, UpdateFlags, UpdateStreamMessage,\n};\nuse dashmap::DashMap;\nuse database::collab::{CollabStore, GetCollabOrigin};\nuse database_entity::dto::CollabParams;\nuse futures::{pin_mut, Sink, Stream};\nuse futures_util::{SinkExt, StreamExt};\nuse indexer::scheduler::{IndexerScheduler, UnindexedCollabTask, UnindexedData};\nuse std::sync::atomic::{AtomicU32, Ordering};\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::time::MissedTickBehavior;\nuse tokio_util::sync::CancellationToken;\nuse tracing::{debug, error, instrument, trace, warn};\nuse uuid::Uuid;\nuse yrs::sync::AwarenessUpdate;\nuse yrs::updates::decoder::{Decode, DecoderV1};\nuse yrs::updates::encoder::{Encode, Encoder, EncoderV1};\nuse yrs::{DeleteSet, ReadTxn, StateVector, Update};\n\n/// A group used to manage a single [Collab] object\npub struct CollabGroup {\n  state: Arc<CollabGroupState>,\n}\n\n/// Inner state of [CollabGroup] that's private and hidden behind Arc, so that it can be moved into\n/// tasks.\nstruct CollabGroupState {\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  /// A list of subscribers to this group. Each subscriber will receive updates from the\n  /// broadcast.\n  subscribers: DashMap<RealtimeUser, Subscription>,\n  persister: CollabPersister,\n  metrics: Arc<CollabRealtimeMetrics>,\n  /// Cancellation token triggered when current collab group is about to be stopped.\n  /// This will also shut down all subsequent [Subscription]s.\n  shutdown: CancellationToken,\n  last_activity: ArcSwap<Instant>,\n  seq_no: AtomicU32,\n  /// The most recent state vector from a redis update.\n  state_vector: RwLock<StateVector>,\n}\n\nimpl Drop for CollabGroup {\n  fn drop(&mut self) {\n    // we're going to use state shutdown to cancel subsequent tasks\n    self.state.shutdown.cancel();\n  }\n}\n\nimpl CollabGroup {\n  #[allow(clippy::too_many_arguments)]\n  pub async fn new(\n    uid: i64,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    metrics: Arc<CollabRealtimeMetrics>,\n    storage: Arc<dyn CollabStore>,\n    collab_redis_stream: Arc<CollabRedisStream>,\n    persistence_interval: Duration,\n    state_vector: StateVector,\n    indexer_scheduler: Arc<IndexerScheduler>,\n  ) -> Result<Self, StreamError> {\n    let is_new_collab = state_vector.is_empty();\n    let persister = CollabPersister::new(\n      uid,\n      workspace_id,\n      object_id,\n      collab_type,\n      storage,\n      collab_redis_stream,\n      indexer_scheduler,\n      metrics.clone(),\n    )\n    .await?;\n\n    let state = Arc::new(CollabGroupState {\n      workspace_id,\n      object_id,\n      collab_type,\n      subscribers: DashMap::new(),\n      metrics,\n      shutdown: CancellationToken::new(),\n      persister,\n      last_activity: ArcSwap::new(Instant::now().into()),\n      seq_no: AtomicU32::new(0),\n      state_vector: state_vector.into(),\n    });\n\n    /*\n     NOTE: we don't want to pass `Weak<CollabGroupState>` to tasks and terminate them when they\n     cannot be upgraded since we want to be sure that ie. when collab group is to be removed,\n     that we're going to call for a final save of the document state.\n\n     For that we use `CancellationToken` instead, which is racing against internal loops of child\n     tasks and triggered when this `CollabGroup` is dropped.\n    */\n\n    // setup task used to receive collab updates from Redis\n    {\n      let state = state.clone();\n      tokio::spawn(async move {\n        if let Err(err) = Self::inbound_task(state).await {\n          tracing::warn!(\"failed to receive collab update: {}\", err);\n        }\n      });\n    }\n\n    // setup task used to receive awareness updates from Redis\n    {\n      let state = state.clone();\n      tokio::spawn(async move {\n        if let Err(err) = Self::inbound_awareness_task(state).await {\n          tracing::warn!(\"failed to receive awareness update: {}\", err);\n        }\n      });\n    }\n\n    // setup periodic snapshot\n    {\n      tokio::spawn(Self::snapshot_task(\n        state.clone(),\n        persistence_interval,\n        is_new_collab,\n      ));\n    }\n\n    Ok(Self { state })\n  }\n\n  #[inline]\n  pub fn workspace_id(&self) -> &Uuid {\n    &self.state.workspace_id\n  }\n\n  #[inline]\n  #[allow(dead_code)]\n  pub fn object_id(&self) -> &Uuid {\n    &self.state.object_id\n  }\n\n  pub fn is_cancelled(&self) -> bool {\n    self.state.shutdown.is_cancelled()\n  }\n\n  /// Task used to receive collab updates from Redis.\n  async fn inbound_task(state: Arc<CollabGroupState>) -> Result<(), RealtimeError> {\n    let updates = state.persister.collab_redis_stream.live_collab_updates(\n      &state.workspace_id,\n      &state.object_id,\n      None,\n    );\n    pin_mut!(updates);\n    loop {\n      tokio::select! {\n        _ = state.shutdown.cancelled() => {\n          break;\n        }\n        res = updates.next() => {\n          match res {\n            Some(Ok((message_id, update))) => {\n              state.metrics.observe_collab_stream_latency(message_id.timestamp_ms);\n              state.persister.storage.mark_as_editing(state.object_id);\n              Self::handle_inbound_update(&state, update).await;\n            },\n            Some(Err(err)) => {\n              tracing::warn!(\"failed to handle incoming update for collab `{}`: {}\", state.object_id, err);\n              break;\n            },\n            None => {\n              break;\n            }\n          }\n        }\n      }\n    }\n    Ok(())\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn handle_inbound_update(state: &CollabGroupState, update: CollabStreamUpdate) {\n    let sender = update.sender.clone();\n    match update.into_update() {\n      Ok(update) => {\n        let mut update_sv = StateVector::default();\n        let insertions = update.insertions(true);\n        let del_set = DeleteSet::from(insertions);\n        for (&client, blocks) in del_set.iter() {\n          if blocks.is_empty() {\n            continue;\n          }\n          let upper = blocks.iter().map(|b| b.end).max().unwrap();\n          update_sv.set_max(client, upper);\n        }\n\n        trace!(\n          \"receive inbound {}/{} update {:#?}, state vector: {:#?}, server state vector: {:#?}\",\n          state.object_id,\n          state.collab_type,\n          update,\n          update_sv,\n          state.state_vector.read().await,\n        );\n        state.state_vector.write().await.merge(update_sv);\n\n        let seq_num = state.seq_no.fetch_add(1, Ordering::SeqCst) + 1;\n        let payload = Message::Sync(SyncMessage::Update(update.encode_v1())).encode_v1();\n        let message = BroadcastSync::new(sender, state.object_id.to_string(), payload, seq_num);\n        for mut e in state.subscribers.iter_mut() {\n          let subscription = e.value_mut();\n          if message.origin == subscription.collab_origin {\n            continue; // don't send update to its sender\n          }\n\n          if let Err(err) = subscription.sink.send(message.clone().into()).await {\n            tracing::debug!(\n              \"failed to send collab `{}` update to `{}`: {}\",\n              state.object_id,\n              subscription.collab_origin,\n              err\n            );\n          }\n\n          state.last_activity.store(Arc::new(Instant::now()));\n        }\n      },\n      Err(err) => {\n        tracing::error!(\n          \"received malformed update for collab `{}`: {}\",\n          state.object_id,\n          err\n        );\n      },\n    }\n  }\n\n  /// Task used to receive awareness updates from Redis.\n  async fn inbound_awareness_task(state: Arc<CollabGroupState>) -> Result<(), RealtimeError> {\n    let object_id = state.object_id;\n    let updates = state\n      .persister\n      .collab_redis_stream\n      .awareness_updates(&object_id);\n    pin_mut!(updates);\n    loop {\n      tokio::select! {\n        _ = state.shutdown.cancelled() => {\n          break;\n        }\n        res = updates.recv() => {\n          match res {\n            Some(awareness_update) => {\n              Self::handle_inbound_awareness(&state, &awareness_update).await;\n            },\n            None => {\n              break;\n            }\n          }\n        }\n      }\n    }\n    Ok(())\n  }\n\n  async fn handle_inbound_awareness(state: &CollabGroupState, update: &AwarenessStreamUpdate) {\n    tracing::trace!(\"broadcast awareness {:#?}\", update.data,);\n    let sender = &update.sender;\n    let message = AwarenessSync::new(\n      state.object_id.to_string(),\n      Message::Awareness(update.data.encode_v1()).encode_v1(),\n      CollabOrigin::Empty,\n    );\n    for mut e in state.subscribers.iter_mut() {\n      let subscription = e.value_mut();\n      if sender == &subscription.collab_origin {\n        continue; // don't send update to its sender\n      }\n\n      if let Err(err) = subscription.sink.send(message.clone().into()).await {\n        tracing::debug!(\n          \"failed to send awareness `{}` update to `{}`: {}\",\n          state.object_id,\n          subscription.collab_origin,\n          err\n        );\n      }\n\n      state.last_activity.store(Arc::new(Instant::now()));\n    }\n  }\n\n  async fn snapshot_task(state: Arc<CollabGroupState>, interval: Duration, is_new_collab: bool) {\n    if is_new_collab {\n      tracing::trace!(\"persisting new collab for {}\", state.object_id);\n      if let Err(err) = state.persister.save().await {\n        tracing::warn!(\n          \"failed to persist new document `{}`: {}\",\n          state.object_id,\n          err\n        );\n      }\n    }\n\n    let mut snapshot_tick = tokio::time::interval(interval);\n    // if saving took longer than snapshot_tick, just skip it over and try in the next round\n    snapshot_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);\n\n    loop {\n      tokio::select! {\n        _ = snapshot_tick.tick() => {\n          if let Err(err) = state.persister.save().await {\n            tracing::warn!(\"failed to persist collab `{}/{}`: {}\", state.workspace_id, state.object_id, err);\n          }\n        },\n        _ = state.shutdown.cancelled() => {\n          if let Err(err) = state.persister.save().await {\n            tracing::warn!(\"failed to persist collab on shutdown `{}/{}`: {}\", state.workspace_id, state.object_id, err);\n          }\n          break;\n        }\n      }\n    }\n  }\n\n  pub async fn calculate_missing_update(\n    &self,\n    state_vector: StateVector,\n  ) -> Result<Vec<u8>, RealtimeError> {\n    {\n      // first check if we need to send any updates\n      let collab_sv = self.state.state_vector.read().await;\n      if *collab_sv <= state_vector {\n        return Ok(vec![]);\n      }\n    }\n\n    let encoded_collab = self.encode_collab().await?;\n    let options = CollabOptions::new(self.object_id().to_string(), default_client_id())\n      .with_data_source(DataSource::DocStateV1(encoded_collab.doc_state.into()));\n    let collab = Collab::new_with_options(CollabOrigin::Server, options)?;\n    let update = collab.transact().encode_state_as_update_v1(&state_vector);\n    Ok(update)\n  }\n\n  /// Generate embedding for the current Collab immediately\n  ///\n  pub async fn generate_embeddings(&self) -> Result<(), AppError> {\n    let collab = self\n      .encode_collab()\n      .await\n      .map_err(|e| AppError::Internal(e.into()))?;\n    let options = CollabOptions::new(self.object_id().to_string(), default_client_id())\n      .with_data_source(DataSource::DocStateV1(collab.doc_state.into()));\n    let collab = Collab::new_with_options(CollabOrigin::Server, options)\n      .map_err(|e| AppError::Internal(e.into()))?;\n    let workspace_id = self.state.workspace_id;\n    let object_id = self.state.object_id;\n    let collab_type = self.state.collab_type;\n    self\n      .state\n      .persister\n      .indexer_scheduler\n      .index_collab_immediately(workspace_id, object_id, &collab, collab_type)\n      .await\n  }\n\n  pub async fn encode_collab(&self) -> Result<EncodedCollab, RealtimeError> {\n    let snapshot = self.state.persister.load_compact().await?;\n    let encode_collab = snapshot.collab.encode_collab_v1(|collab| {\n      self\n        .state\n        .collab_type\n        .validate_require_data(collab)\n        .map_err(|err| RealtimeError::CollabSchemaError(err.to_string()))\n    })?;\n    Ok(encode_collab)\n  }\n\n  pub fn contains_user(&self, user: &RealtimeUser) -> bool {\n    self.state.subscribers.contains_key(user)\n  }\n\n  pub fn remove_user(&self, user: &RealtimeUser) {\n    if self.state.subscribers.remove(user).is_some() {\n      trace!(\n        \"{} remove subscriber from group: {}\",\n        self.state.object_id,\n        user\n      );\n    }\n  }\n\n  pub fn user_count(&self) -> usize {\n    self.state.subscribers.len()\n  }\n\n  pub fn modified_at(&self) -> Instant {\n    *self.state.last_activity.load_full()\n  }\n\n  /// Subscribes a new connection to the broadcast group for collaborative activities.\n  ///\n  pub fn subscribe<Sink, Stream>(\n    &self,\n    user: &RealtimeUser,\n    subscriber_origin: CollabOrigin,\n    sink: Sink,\n    stream: Stream,\n  ) where\n    Sink: SubscriptionSink + Clone + 'static,\n    Stream: SubscriptionStream + 'static,\n  {\n    // create new subscription for new subscriber\n    let subscriber_shutdown = self.state.shutdown.child_token();\n\n    tokio::spawn(Self::receive_from_client_task(\n      self.state.clone(),\n      sink.clone(),\n      stream,\n      subscriber_origin.clone(),\n    ));\n\n    let sub = Subscription::new(sink, subscriber_origin, subscriber_shutdown);\n    if self\n      .state\n      .subscribers\n      .insert((*user).clone(), sub)\n      .is_some()\n    {\n      tracing::warn!(\"{}: remove old subscriber: {}\", &self.state.object_id, user);\n    }\n\n    if cfg!(debug_assertions) {\n      trace!(\n        \"{}: add new subscriber, current group member: {}\",\n        &self.state.object_id,\n        self.user_count(),\n      );\n    }\n\n    trace!(\n      \"[realtime]:{} new subscriber:{}, connect at:{}, connected members: {}\",\n      self.state.object_id,\n      user.user_device(),\n      user.connect_at,\n      self.state.subscribers.len(),\n    );\n  }\n\n  async fn receive_from_client_task<Sink, Stream>(\n    state: Arc<CollabGroupState>,\n    mut sink: Sink,\n    mut stream: Stream,\n    origin: CollabOrigin,\n  ) where\n    Sink: SubscriptionSink + 'static,\n    Stream: SubscriptionStream + 'static,\n  {\n    loop {\n      tokio::select! {\n        _ = state.shutdown.cancelled() => {\n          break;\n        }\n        msg = stream.next() => {\n          match msg {\n            None => break,\n            Some(msg) => if let Err(err) =  Self::handle_messages(&state, &mut sink, msg).await {\n              tracing::warn!(\n                \"collab `{}` failed to handle message from `{}`: {}\",\n                state.object_id,\n                origin,\n                err\n              );\n\n            }\n          }\n        }\n      }\n    }\n  }\n\n  async fn handle_messages<Sink>(\n    state: &CollabGroupState,\n    sink: &mut Sink,\n    msg: MessageByObjectId,\n  ) -> Result<(), RealtimeError>\n  where\n    Sink: SubscriptionSink + 'static,\n  {\n    let object_id = state.object_id.to_string();\n    for (message_object_id, messages) in msg.0 {\n      if object_id != message_object_id {\n        error!(\n          \"Expect object id:{} but got:{}\",\n          state.object_id, message_object_id\n        );\n        continue;\n      }\n      for message in messages {\n        match Self::handle_client_message(state, message).await {\n          Ok(response) => match sink.send(response.into()).await {\n            Ok(()) => {},\n            Err(err) => {\n              trace!(\"[realtime]: send failed: {}\", err);\n              break;\n            },\n          },\n          Err(err) => {\n            error!(\n              \"Error handling collab message for object_id: {}: {}\",\n              message_object_id, err\n            );\n            break;\n          },\n        }\n      }\n    }\n    Ok(())\n  }\n\n  /// Handle the message sent from the client\n  async fn handle_client_message(\n    state: &CollabGroupState,\n    collab_msg: ClientCollabMessage,\n  ) -> Result<CollabAck, RealtimeError> {\n    let msg_id = collab_msg.msg_id();\n    let message_origin = collab_msg.origin().clone();\n\n    // If the payload is empty, we don't need to apply any updates .\n    // Currently, only the ping message should has an empty payload.\n    if collab_msg.payload().is_empty() {\n      if !matches!(collab_msg, ClientCollabMessage::ClientCollabStateCheck(_)) {\n        error!(\"receive unexpected empty payload message:{}\", collab_msg);\n      }\n      return Ok(CollabAck::new(\n        message_origin,\n        state.object_id.to_string(),\n        msg_id,\n        state.seq_no.load(Ordering::SeqCst),\n      ));\n    }\n\n    let payload = collab_msg.payload();\n\n    // Spawn a blocking task to handle the message\n    let result = Self::handle_message(state, payload, &message_origin, msg_id).await;\n\n    match result {\n      Ok(inner_result) => match inner_result {\n        Some(response) => Ok(response),\n        None => Err(RealtimeError::UnexpectedData(\"No ack response\")),\n      },\n      Err(err) => Err(RealtimeError::Internal(anyhow!(\n        \"fail to handle message:{}\",\n        err\n      ))),\n    }\n  }\n\n  async fn handle_message(\n    state: &CollabGroupState,\n    payload: &[u8],\n    message_origin: &CollabOrigin,\n    msg_id: MsgId,\n  ) -> Result<Option<CollabAck>, RealtimeError> {\n    let mut decoder = DecoderV1::from(payload);\n    let reader = MessageReader::new(&mut decoder);\n    let mut ack_response = None;\n    for msg in reader {\n      match msg {\n        Ok(msg) => {\n          match Self::handle_protocol_message(state, message_origin, msg).await {\n            Ok(payload) => {\n              // One ClientCollabMessage can have multiple Yrs [Message] in it, but we only need to\n              // send one ack back to the client.\n              if ack_response.is_none() {\n                ack_response = Some(\n                  CollabAck::new(\n                    CollabOrigin::Server,\n                    state.object_id.to_string(),\n                    msg_id,\n                    state.seq_no.load(Ordering::SeqCst),\n                  )\n                  .with_payload(payload.unwrap_or_default()),\n                );\n              }\n            },\n            Err(err) => {\n              tracing::warn!(\"[realtime]: failed to handled message: {}\", err);\n              state.metrics.apply_update_failed_count.inc();\n\n              let code = Self::ack_code_from_error(&err);\n              let payload = match err {\n                RTProtocolError::MissUpdates {\n                  state_vector_v1,\n                  reason: _,\n                } => state_vector_v1.unwrap_or_default(),\n                _ => vec![],\n              };\n\n              ack_response = Some(\n                CollabAck::new(\n                  CollabOrigin::Server,\n                  state.object_id.to_string(),\n                  msg_id,\n                  state.seq_no.load(Ordering::SeqCst),\n                )\n                .with_code(code)\n                .with_payload(payload),\n              );\n\n              break;\n            },\n          }\n        },\n        Err(e) => {\n          error!(\"{} => parse sync message failed: {}\", state.object_id, e);\n          break;\n        },\n      }\n    }\n    Ok(ack_response)\n  }\n\n  async fn handle_protocol_message(\n    state: &CollabGroupState,\n    origin: &CollabOrigin,\n    msg: Message,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    match msg {\n      Message::Sync(msg) => match msg {\n        SyncMessage::SyncStep1(sv) => Self::handle_sync_step1(state, &sv).await,\n        SyncMessage::SyncStep2(update) => Self::handle_sync_step2(state, origin, update).await,\n        SyncMessage::Update(update) => Self::handle_update(state, origin, update).await,\n      },\n      //FIXME: where is the QueryAwareness protocol?\n      Message::Awareness(update) => Self::handle_awareness_update(state, origin, update).await,\n      Message::Auth(_reason) => Ok(None),\n      Message::Custom(_msg) => Ok(None),\n    }\n  }\n\n  async fn handle_sync_step1(\n    state: &CollabGroupState,\n    client_sv: &StateVector,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    if let Ok(sv) = state.state_vector.try_read() {\n      trace!(\n        \"Sync step1: {}/{} server state vector: {:#?}, client state vector: {:#?}\",\n        state.object_id,\n        state.collab_type,\n        sv,\n        client_sv\n      );\n      // we optimistically try to obtain state vector lock for a fast track:\n      // if we remote sv is up-to-date with current one, we don't need to do anything\n      match sv.partial_cmp(client_sv) {\n        Some(std::cmp::Ordering::Equal) => {\n          trace!(\n            \"Sync step1: {}/{} client and server are synced, no need to send anything\",\n            state.object_id,\n            state.collab_type\n          );\n          return Ok(None);\n        }, // client and server are in sync\n        Some(std::cmp::Ordering::Less) => {\n          // server is behind client\n          trace!(\n            \"Sync step1: server {}/{} is behind client, sending sync step 1 with state vector: {:#?}\",\n            state.object_id, state.collab_type,\n            sv\n          );\n          let msg = Message::Sync(SyncMessage::SyncStep1(sv.clone()));\n          return Ok(Some(msg.encode_v1()));\n        },\n        Some(std::cmp::Ordering::Greater) | None => {\n          // server has some new updates\n          trace!(\n            \"Sync step1: server has some new updates for {}/{}\",\n            state.object_id,\n            state.collab_type\n          );\n        },\n      }\n    }\n\n    // we need to reconstruct document state on the server side\n    tracing::debug!(\"loading collab {}\", state.object_id);\n    let snapshot = state\n      .persister\n      .load_compact()\n      .await\n      .map_err(|err| RTProtocolError::Internal(err.into()))?;\n\n    // prepare document state update and state vector\n    let tx = snapshot.collab.transact();\n    let doc_state = tx.encode_diff_v1(client_sv);\n    let local_sv = tx.state_vector();\n    drop(tx);\n\n    // Retrieve the latest document state from the client after they return online from offline editing.\n    tracing::trace!(\"sending missing data to client ({} bytes)\", doc_state.len());\n    let mut encoder = EncoderV1::new();\n    Message::Sync(SyncMessage::SyncStep2(doc_state)).encode(&mut encoder);\n    //FIXME: this should never happen as response to sync step 1 from the client, but rather be\n    //  send when a connection is established\n    Message::Sync(SyncMessage::SyncStep1(local_sv)).encode(&mut encoder);\n    Ok(Some(encoder.to_vec()))\n  }\n\n  async fn handle_sync_step2(\n    state: &CollabGroupState,\n    origin: &CollabOrigin,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    state.metrics.collab_size.observe(update.len() as f64);\n\n    let start = tokio::time::Instant::now();\n    // we try to decode update to make sure it's not malformed and to extract state vector\n    let (update, decoded_update) = if update.len() <= collab_rt_protocol::LARGE_UPDATE_THRESHOLD {\n      let decoded_update = Update::decode_v1(&update)?;\n      (update, decoded_update)\n    } else {\n      tokio::task::spawn_blocking(move || {\n        let decoded_update = Update::decode_v1(&update)?;\n        Ok::<(Vec<u8>, yrs::Update), yrs::encoding::read::Error>((update, decoded_update))\n      })\n      .await\n      .map_err(|err| RTProtocolError::Internal(err.into()))??\n    };\n\n    state\n      .metrics\n      .load_collab_time\n      .observe(start.elapsed().as_millis() as f64);\n\n    let (should_apply_update, missing_updates) = {\n      let current_sv = state.state_vector.read().await;\n      let update_sv = decoded_update.state_vector_lower();\n      trace!(\n        \"Sync step2: {}/{} new update: {:#?}, state vector: {:#?}, server state vector: {:#?}\",\n        state.object_id,\n        state.collab_type,\n        decoded_update,\n        update_sv,\n        current_sv,\n      );\n      match current_sv.partial_cmp(&update_sv) {\n        None => {\n          // Concurrent state vectors: both server and client have updates the other hasn't seen\n          // We should apply the update to merge the states, then inform the client of our state\n          trace!(\n            \"Sync step2: {}/{} concurrent state vectors, current: {:#?}, update: {:#?}\",\n            state.object_id,\n            state.collab_type,\n            current_sv,\n            update_sv\n          );\n          (\n            true,\n            Some(Message::Sync(SyncMessage::SyncStep1(current_sv.clone())).encode_v1()),\n          )\n        },\n        Some(std::cmp::Ordering::Less) => {\n          if !decoded_update.extends(&current_sv) {\n            // server is behind client, but the update doesn't extend current server state\n            // which means that we must have missed some updates, that must be integrated\n            // before current update can be fully applied\n            trace!(\n              \"Sync step2: server {}/{} is behind client\",\n              state.object_id,\n              state.collab_type,\n            );\n            return Ok(Some(\n              Message::Sync(SyncMessage::SyncStep1(current_sv.clone())).encode_v1(),\n            ));\n          } else {\n            (true, None)\n          }\n        },\n        Some(std::cmp::Ordering::Equal) => {\n          trace!(\n            \"Sync step2: {}/{} server and client are synced\",\n            state.object_id,\n            state.collab_type,\n          );\n          (true, None)\n        },\n        Some(std::cmp::Ordering::Greater) => {\n          trace!(\n            \"Sync step2: {}/{} server is ahead of client\",\n            state.object_id,\n            state.collab_type,\n          );\n          (true, None)\n        },\n      }\n    };\n\n    if should_apply_update {\n      state\n        .persister\n        .send_update(origin.clone(), update)\n        .await\n        .map_err(|err| RTProtocolError::Internal(err.into()))?;\n    }\n\n    Ok(missing_updates)\n  }\n\n  async fn handle_update(\n    state: &CollabGroupState,\n    origin: &CollabOrigin,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    Self::handle_sync_step2(state, origin, update).await\n  }\n\n  async fn handle_awareness_update(\n    state: &CollabGroupState,\n    origin: &CollabOrigin,\n    update: Vec<u8>,\n  ) -> Result<Option<Vec<u8>>, RTProtocolError> {\n    let awareness_update = AwarenessUpdate::decode_v1(&update)?;\n    trace!(\"awareness updates: {:#?}\", awareness_update);\n    state\n      .persister\n      .send_awareness(origin, awareness_update)\n      .await\n      .map_err(|err| RTProtocolError::Internal(err.into()))?;\n    Ok(None)\n  }\n\n  #[inline]\n  fn ack_code_from_error(error: &RTProtocolError) -> AckCode {\n    match error {\n      RTProtocolError::YrsTransaction(_) => AckCode::Retry,\n      RTProtocolError::YrsApplyUpdate(_) => AckCode::CannotApplyUpdate,\n      RTProtocolError::YrsEncodeState(_) => AckCode::EncodeStateAsUpdateFail,\n      RTProtocolError::MissUpdates { .. } => AckCode::MissUpdate,\n      _ => AckCode::Internal,\n    }\n  }\n\n  /// Check if the group is active. A group is considered active if it has at least one\n  /// subscriber\n  pub fn is_inactive(&self) -> bool {\n    let modified_at = self.modified_at();\n    let elapsed_secs = modified_at.elapsed().as_secs();\n    // Mark the group as inactive if it has been inactive for more than 3 hours, regardless of the number of subscribers.\n    // Otherwise, return `true` only if there are no subscribers remaining in the group.\n    // If a client modifies a group that has already been marked as inactive (removed),\n    // the client will automatically send an initialization sync to reinitialize the group.\n    const MAXIMUM_SECS: u64 = 3 * 60 * 60;\n    if elapsed_secs > MAXIMUM_SECS {\n      debug!(\n        \"Group:{}:{} is inactive for {} seconds, subscribers: {}\",\n        self.state.object_id,\n        self.state.collab_type,\n        modified_at.elapsed().as_secs(),\n        self.state.subscribers.len()\n      );\n      true\n    } else {\n      self.state.subscribers.is_empty()\n    }\n  }\n}\n\npub trait SubscriptionSink:\n  Sink<CollabMessage, Error = RealtimeError> + Send + Sync + Unpin\n{\n}\nimpl<T> SubscriptionSink for T where\n  T: Sink<CollabMessage, Error = RealtimeError> + Send + Sync + Unpin\n{\n}\n\npub trait SubscriptionStream: Stream<Item = MessageByObjectId> + Send + Sync + Unpin {}\nimpl<T> SubscriptionStream for T where T: Stream<Item = MessageByObjectId> + Send + Sync + Unpin {}\n\nstruct Subscription {\n  collab_origin: CollabOrigin,\n  sink: Box<dyn SubscriptionSink>,\n  shutdown: CancellationToken,\n}\n\nimpl Subscription {\n  fn new<S>(sink: S, collab_origin: CollabOrigin, shutdown: CancellationToken) -> Self\n  where\n    S: SubscriptionSink + 'static,\n  {\n    Subscription {\n      sink: Box::new(sink),\n      collab_origin,\n      shutdown,\n    }\n  }\n}\n\nimpl Drop for Subscription {\n  fn drop(&mut self) {\n    tracing::trace!(\"closing subscription: {}\", self.collab_origin);\n    self.shutdown.cancel();\n  }\n}\n\nstruct CollabPersister {\n  uid: i64,\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  storage: Arc<dyn CollabStore>,\n  collab_redis_stream: Arc<CollabRedisStream>,\n  indexer_scheduler: Arc<IndexerScheduler>,\n  metrics: Arc<CollabRealtimeMetrics>,\n  update_sink: CollabUpdateSink,\n  awareness_sink: AwarenessUpdateSink,\n}\n\nimpl CollabPersister {\n  #[allow(clippy::too_many_arguments)]\n  pub async fn new(\n    uid: i64,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n    storage: Arc<dyn CollabStore>,\n    collab_redis_stream: Arc<CollabRedisStream>,\n    indexer_scheduler: Arc<IndexerScheduler>,\n    metrics: Arc<CollabRealtimeMetrics>,\n  ) -> Result<Self, StreamError> {\n    let update_sink =\n      collab_redis_stream.collab_update_sink(&workspace_id, &object_id, collab_type);\n    let awareness_sink = collab_redis_stream\n      .awareness_update_sink(&workspace_id, &object_id)\n      .await?;\n    Ok(Self {\n      uid,\n      workspace_id,\n      object_id,\n      collab_type,\n      storage,\n      collab_redis_stream,\n      indexer_scheduler,\n      metrics,\n      update_sink,\n      awareness_sink,\n    })\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn send_update(\n    &self,\n    sender: CollabOrigin,\n    update: Vec<u8>,\n  ) -> Result<MessageId, StreamError> {\n    let len = update.len();\n    // send updates to redis queue\n    let update = CollabStreamUpdate::new(update, sender, UpdateFlags::default());\n    let msg_id = self.update_sink.send(&update).await?;\n    self.storage.mark_as_editing(self.object_id);\n    tracing::trace!(\n      \"persisted update for {} from {} ({} bytes) - msg id: {}\",\n      self.object_id,\n      update.sender,\n      len,\n      msg_id\n    );\n    Ok(msg_id)\n  }\n\n  async fn send_awareness(\n    &self,\n    sender_session: &CollabOrigin,\n    awareness_update: AwarenessUpdate,\n  ) -> Result<(), StreamError> {\n    let update = AwarenessStreamUpdate {\n      data: awareness_update,\n      sender: sender_session.clone(),\n    };\n    self.awareness_sink.send(&update).await?;\n    trace!(\"broadcast awareness {:#?}\", update);\n    Ok(())\n  }\n\n  /// Loads collab without its history. Used for handling y-sync protocol messages.\n  async fn load_compact(&self) -> Result<CollabSnapshot, RealtimeError> {\n    tracing::trace!(\"requested to load compact collab {}\", self.object_id);\n    // 1. Try to load the latest snapshot from storage\n    let start = Instant::now();\n    let (rid, mut collab) = match self.load_collab_full().await? {\n      Some((rid, collab)) => (rid, collab),\n      None => {\n        let options = CollabOptions::new(self.object_id.to_string(), default_client_id());\n        let collab = Collab::new_with_options(CollabOrigin::Server, options)?;\n        (Rid::default(), collab)\n      },\n    };\n    self.metrics.load_collab_count.inc();\n    let since = MessageId::new(rid.timestamp, rid.seq_no);\n\n    // 2. consume all Redis updates on top of it (keep redis msg id)\n    let mut applied_messages = Vec::new();\n    let mut tx = collab.transact_mut();\n    let updates = self\n      .collab_redis_stream\n      .current_collab_updates(&self.workspace_id, &self.object_id, Some(since))\n      .await?;\n    let mut i = 0;\n    for (message_id, update) in updates {\n      i += 1;\n      let update: Update = update.into_update()?;\n      tx.apply_update(update).map_err(|err| {\n        RTProtocolError::YrsApplyUpdate(format!(\"collab {} - {}\", self.object_id, err))\n      })?;\n      applied_messages.push(message_id); //TODO: shouldn't this happen before decoding?\n      self.metrics.apply_update_count.inc();\n    }\n\n    drop(tx);\n    tracing::trace!(\n      \"loaded collab compact state: {} replaying {} updates\",\n      self.object_id,\n      i\n    );\n    self\n      .metrics\n      .load_collab_time\n      .observe(start.elapsed().as_millis() as f64);\n\n    // now we have the most recent version of the document\n    let snapshot = CollabSnapshot {\n      collab,\n      rid,\n      applied_messages,\n    };\n    Ok(snapshot)\n  }\n\n  /// Returns a collab state (with GC turned off), but only if there were any pending updates\n  /// waiting to be merged into main document state.\n  async fn load_if_changed(&self) -> Result<Option<CollabSnapshot>, RealtimeError> {\n    // 1. load pending Redis updates\n    let updates = self\n      .collab_redis_stream\n      .current_collab_updates(&self.workspace_id, &self.object_id, None)\n      .await?;\n\n    if updates.is_empty() {\n      // if there were no Redis updates, collab is still not initialized\n      return Ok(None);\n    }\n\n    let (rid, mut collab) = match self.load_collab_full().await? {\n      Some(collab) => collab,\n      None => {\n        let options = CollabOptions::new(self.object_id.to_string(), default_client_id());\n        let collab = Collab::new_with_options(CollabOrigin::Server, options)?;\n        (Rid::default(), collab)\n      },\n    };\n    let start = Instant::now();\n    let mut i = 0;\n    let mut applied_messages = Vec::new();\n    let mut tx = collab.transact_mut();\n    for (message_id, update) in updates {\n      i += 1;\n      let update: Update = update.into_update()?;\n      tx.apply_update(update).map_err(|err| {\n        RTProtocolError::YrsApplyUpdate(format!(\"collab {} - {}\", self.object_id, err))\n      })?;\n      applied_messages.push(message_id); //TODO: shouldn't this happen before decoding?\n      self.metrics.apply_update_count.inc();\n    }\n    drop(tx);\n\n    self.metrics.load_full_collab_count.inc();\n    let elapsed = start.elapsed();\n    self\n      .metrics\n      .load_collab_time\n      .observe(elapsed.as_millis() as f64);\n    tracing::trace!(\n      \"loaded collab full state: {} replaying {} updates in {:?}\",\n      self.object_id,\n      i,\n      elapsed\n    );\n    {\n      let tx = collab.transact();\n      if tx.store().pending_update().is_some() || tx.store().pending_ds().is_some() {\n        tracing::trace!(\n          \"loaded collab {} is incomplete: has pending data\",\n          self.object_id\n        );\n      }\n    }\n    Ok(Some(CollabSnapshot {\n      collab,\n      rid,\n      applied_messages,\n    }))\n  }\n\n  async fn save(&self) -> Result<(), RealtimeError> {\n    // load collab but only if there were pending updates in Redis\n    if let Some(snapshot) = self.load_if_changed().await? {\n      tracing::debug!(\"requesting save for collab {}\", self.object_id);\n      if !snapshot.applied_messages.is_empty() {\n        // non-nil message_id means that we had to update the most recent collab state snapshot\n        // with new updates from Redis. This means that our snapshot state is newer than the last\n        // persisted one in the database\n        self.save_attempt(snapshot).await?;\n      }\n    }\n    Ok(())\n  }\n\n  /// Tries to save provided `snapshot`. This snapshot is expected to have **GC turned off**, as\n  /// first it will try to save it as a historical snapshot (will all updates available), then it\n  /// will generate another (compact) snapshot variant that will be used as main one for loading\n  /// for the sake of y-sync protocol.\n  async fn save_attempt(&self, snapshot: CollabSnapshot) -> Result<(), RealtimeError> {\n    // try to acquire snapshot lease - it's possible that multiple web services will try to\n    // perform snapshot at the same time, so we'll use lease to let only one of them atm.\n    let last_message_id = snapshot.applied_messages.last().cloned().ok_or_else(|| {\n      RealtimeError::CreateSnapshotFailed(\n        \"only save snapshot after some updates were applied\".into(),\n      )\n    })?;\n    if let Some(mut lease) = self\n      .collab_redis_stream\n      .lease(&self.workspace_id.to_string(), &self.object_id.to_string())\n      .await?\n    {\n      let collab = snapshot.collab;\n      // encode_diff doesn't include pending updates\n      let tx = collab.transact();\n      let doc_state_light = tx.encode_diff_v1(&StateVector::default());\n      let state_vector = tx.state_vector();\n      let light_len = doc_state_light.len();\n      self\n        .write_collab(doc_state_light, state_vector, snapshot.rid.timestamp)\n        .await?;\n\n      match self.collab_type {\n        CollabType::Document => {\n          let txn = collab.transact();\n          if let Some(text) = DocumentBody::from_collab(&collab).map(|body| body.to_plain_text(txn))\n          {\n            self.index_collab_content(text);\n          }\n        },\n        _ => {\n          // TODO(nathan): support other collab type\n        },\n      }\n\n      tracing::debug!(\n        \"persisted collab {} snapshot at {}: {} bytes\",\n        self.object_id,\n        last_message_id,\n        light_len\n      );\n\n      // 3. finally we can drop Redis messages\n      let stream_key = UpdateStreamMessage::stream_key(&self.workspace_id);\n      self\n        .collab_redis_stream\n        .delete_stream_messages(&stream_key, &snapshot.applied_messages)\n        .await?;\n\n      let _ = lease.release().await;\n    }\n\n    Ok(())\n  }\n\n  async fn write_collab(\n    &self,\n    doc_state_v1: Vec<u8>,\n    state_vector: StateVector,\n    mills_secs: u64,\n  ) -> Result<(), RealtimeError> {\n    let updated_at = DateTime::<Utc>::from_timestamp_millis(mills_secs as i64);\n    let encoded_collab = EncodedCollab::new_v1(state_vector.encode_v1(), doc_state_v1)\n      .encode_to_bytes()\n      .map(Bytes::from)\n      .map_err(|err| RealtimeError::BincodeEncode(err.to_string()))?;\n    self\n      .metrics\n      .collab_size\n      .observe(encoded_collab.len() as f64);\n    let params = CollabParams {\n      object_id: self.object_id,\n      encoded_collab_v1: encoded_collab,\n      collab_type: self.collab_type,\n      updated_at,\n    };\n    self\n      .storage\n      .upsert_collab(self.workspace_id, &self.uid, params)\n      .await\n      .map_err(|err| RealtimeError::Internal(err.into()))?;\n    Ok(())\n  }\n\n  fn index_collab_content(&self, paragraphs: Vec<String>) {\n    let indexed_collab = UnindexedCollabTask::new(\n      self.workspace_id,\n      self.object_id,\n      self.collab_type,\n      UnindexedData::Paragraphs(paragraphs),\n    );\n    if let Err(err) = self\n      .indexer_scheduler\n      .index_pending_collab_one(indexed_collab, false)\n    {\n      warn!(\n        \"failed to index collab `{}/{}`: {}\",\n        self.workspace_id, self.object_id, err\n      );\n    }\n  }\n\n  #[instrument(level = \"trace\", skip_all)]\n  async fn load_collab_full(&self) -> Result<Option<(Rid, Collab)>, RealtimeError> {\n    // we didn't find a snapshot, or we want a lightweight collab version\n    let result = self\n      .storage\n      .get_full_encode_collab(\n        GetCollabOrigin::Server,\n        &self.workspace_id,\n        &self.object_id,\n        self.collab_type,\n      )\n      .await;\n    let (rid, doc_state) = match result {\n      Ok(value) => (value.rid, value.encoded_collab.doc_state),\n      Err(AppError::RecordNotFound(_)) => return Ok(None),\n      Err(err) => return Err(RealtimeError::Internal(err.into())),\n    };\n\n    let collab: Collab = {\n      let options = CollabOptions::new(self.object_id.to_string(), default_client_id())\n        .with_data_source(DataSource::DocStateV1(doc_state.into()));\n      Collab::new_with_options(CollabOrigin::Server, options)?\n    };\n    Ok(Some((rid, collab)))\n  }\n}\n\npub struct CollabSnapshot {\n  pub collab: Collab,\n  pub rid: Rid,\n  pub applied_messages: Vec<MessageId>,\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/manager.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse access_control::collab::RealtimeAccessControl;\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse collab_entity::CollabType;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::CollabMessage;\nuse collab_stream::client::CollabRedisStream;\nuse database::collab::{CollabStore, GetCollabOrigin};\nuse tracing::trace;\nuse uuid::Uuid;\nuse yrs::{ReadTxn, StateVector};\n\nuse crate::client::client_msg_router::ClientMessageRouter;\nuse crate::error::RealtimeError;\nuse crate::group::group_init::CollabGroup;\nuse crate::group::state::GroupManagementState;\nuse crate::metrics::CollabRealtimeMetrics;\nuse indexer::scheduler::IndexerScheduler;\n\npub struct GroupManager {\n  state: GroupManagementState,\n  storage: Arc<dyn CollabStore>,\n  access_control: Arc<dyn RealtimeAccessControl>,\n  metrics_calculate: Arc<CollabRealtimeMetrics>,\n  collab_redis_stream: Arc<CollabRedisStream>,\n  persistence_interval: Duration,\n  indexer_scheduler: Arc<IndexerScheduler>,\n}\n\nimpl GroupManager {\n  #[allow(clippy::too_many_arguments)]\n  pub async fn new(\n    storage: Arc<dyn CollabStore>,\n    access_control: Arc<dyn RealtimeAccessControl>,\n    metrics_calculate: Arc<CollabRealtimeMetrics>,\n    collab_stream: CollabRedisStream,\n    persistence_interval: Duration,\n    indexer_scheduler: Arc<IndexerScheduler>,\n  ) -> Result<Self, RealtimeError> {\n    let collab_stream = Arc::new(collab_stream);\n    Ok(Self {\n      state: GroupManagementState::new(metrics_calculate.clone()),\n      storage,\n      access_control,\n      metrics_calculate,\n      collab_redis_stream: collab_stream,\n      persistence_interval,\n      indexer_scheduler,\n    })\n  }\n\n  pub fn get_inactive_groups(&self) -> Vec<Uuid> {\n    self.state.remove_inactive_groups()\n  }\n\n  pub fn contains_user(&self, object_id: &Uuid, user: &RealtimeUser) -> bool {\n    self.state.contains_user(object_id, user)\n  }\n\n  pub fn remove_user(&self, user: &RealtimeUser) {\n    self.state.remove_user(user);\n  }\n\n  pub fn contains_group(&self, object_id: &Uuid) -> bool {\n    self.state.contains_group(object_id)\n  }\n\n  pub async fn get_group(&self, object_id: &Uuid) -> Option<Arc<CollabGroup>> {\n    self.state.get_group(object_id).await\n  }\n\n  pub async fn subscribe_group(\n    &self,\n    user: &RealtimeUser,\n    object_id: Uuid,\n    message_origin: &CollabOrigin,\n    client_msg_router: &mut ClientMessageRouter,\n  ) -> Result<(), RealtimeError> {\n    // Lock the group and subscribe the user to the group.\n    if let Some(mut e) = self.state.get_mut_group(&object_id).await {\n      let group = e.value_mut();\n      trace!(\"[realtime]: {} subscribe group:{}\", user, object_id,);\n      let (sink, stream) = client_msg_router.init_client_communication::<CollabMessage>(\n        *group.workspace_id(),\n        user,\n        object_id,\n        self.access_control.clone(),\n      );\n      group.subscribe(user, message_origin.clone(), sink, stream);\n      // explicitly drop the group to release the lock.\n      drop(e);\n\n      self.state.insert_user(user, object_id)?;\n    } else {\n      // When subscribing to a group, the group should exist. Otherwise, it's a bug.\n      return Err(RealtimeError::GroupNotFound(object_id.to_string()));\n    }\n\n    Ok(())\n  }\n\n  pub async fn create_group(\n    &self,\n    user: &RealtimeUser,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    collab_type: CollabType,\n  ) -> Result<(), RealtimeError> {\n    let res = self\n      .storage\n      .get_full_encode_collab(\n        GetCollabOrigin::Server,\n        &workspace_id,\n        &object_id,\n        collab_type,\n      )\n      .await;\n    let state_vector = match res {\n      Ok(value) => {\n        let options = CollabOptions::new(object_id.to_string(), default_client_id())\n          .with_data_source(DataSource::DocStateV1(\n            value.encoded_collab.doc_state.into(),\n          ));\n        Collab::new_with_options(CollabOrigin::Server, options)?\n          .transact()\n          .state_vector()\n      },\n      Err(err) if err.is_record_not_found() => StateVector::default(),\n      Err(err) => return Err(RealtimeError::CannotCreateGroup(err.to_string())),\n    };\n\n    trace!(\n      \"[realtime]: create group: uid:{},workspace_id:{},object_id:{}:{}\",\n      user.uid,\n      workspace_id,\n      object_id,\n      collab_type\n    );\n\n    let group = CollabGroup::new(\n      user.uid,\n      workspace_id,\n      object_id,\n      collab_type,\n      self.metrics_calculate.clone(),\n      self.storage.clone(),\n      self.collab_redis_stream.clone(),\n      self.persistence_interval,\n      state_vector,\n      self.indexer_scheduler.clone(),\n    )\n    .await?;\n    self.state.insert_group(object_id, group);\n    Ok(())\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/mod.rs",
    "content": "pub(crate) mod cmd;\npub(crate) mod group_init;\npub(crate) mod manager;\nmod null_sender;\nmod state;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/null_sender.rs",
    "content": "use crate::error::RealtimeError;\nuse crate::RealtimeClientWebsocketSink;\nuse collab_rt_entity::RealtimeMessage;\nuse futures::Sink;\nuse std::marker::PhantomData;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\n/// Futures [Sink] compatible sender, that always throws the input away.\n/// Essentially: a `/dev/null` equivalent.\n#[derive(Clone)]\npub(crate) struct NullSender<T> {\n  _marker: PhantomData<T>,\n}\n\nimpl<T> Default for NullSender<T> {\n  fn default() -> Self {\n    NullSender {\n      _marker: PhantomData,\n    }\n  }\n}\n\nimpl<T> Sink<T> for NullSender<T> {\n  type Error = RealtimeError;\n\n  #[inline]\n  fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n\n  #[inline]\n  fn start_send(self: Pin<&mut Self>, _: T) -> Result<(), Self::Error> {\n    Ok(())\n  }\n\n  #[inline]\n  fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n\n  #[inline]\n  fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    Poll::Ready(Ok(()))\n  }\n}\n\nimpl<T> RealtimeClientWebsocketSink for NullSender<T>\nwhere\n  T: Send + Sync + 'static,\n{\n  fn do_send(&self, _message: RealtimeMessage) {}\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/group/state.rs",
    "content": "use crate::config::get_env_var;\nuse crate::error::RealtimeError;\nuse crate::group::group_init::CollabGroup;\nuse crate::metrics::CollabRealtimeMetrics;\nuse collab_rt_entity::user::RealtimeUser;\nuse dashmap::mapref::one::RefMut;\nuse dashmap::try_result::TryResult;\nuse dashmap::DashMap;\nuse std::collections::HashSet;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::time::sleep;\nuse tracing::{error, event, trace, warn};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub(crate) struct GroupManagementState {\n  group_by_object_id: Arc<DashMap<Uuid, Arc<CollabGroup>>>,\n  /// Keep track of all [Collab] objects that a user is subscribed to.\n  editing_by_user: Arc<DashMap<RealtimeUser, HashSet<Editing>>>,\n  metrics_calculate: Arc<CollabRealtimeMetrics>,\n  /// By default, the number of groups to remove in a single batch is 50.\n  remove_batch_size: usize,\n}\n\nimpl GroupManagementState {\n  pub(crate) fn new(metrics_calculate: Arc<CollabRealtimeMetrics>) -> Self {\n    let remove_batch_size = get_env_var(\"APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE\", \"50\")\n      .parse::<usize>()\n      .unwrap_or(50);\n    Self {\n      group_by_object_id: Arc::new(DashMap::new()),\n      editing_by_user: Arc::new(DashMap::new()),\n      metrics_calculate,\n      remove_batch_size,\n    }\n  }\n\n  /// Returns group ids of inactive groups.\n  pub fn remove_inactive_groups(&self) -> Vec<Uuid> {\n    let mut inactive_group_ids = vec![];\n    for entry in self.group_by_object_id.iter() {\n      let (object_id, group) = (entry.key(), entry.value());\n      if group.is_inactive() {\n        inactive_group_ids.push(*object_id);\n        if inactive_group_ids.len() > self.remove_batch_size {\n          break;\n        }\n      }\n    }\n    if !inactive_group_ids.is_empty() {\n      trace!(\"inactive group ids:{:?}\", inactive_group_ids);\n    }\n    for object_id in &inactive_group_ids {\n      self.remove_group(object_id);\n    }\n    inactive_group_ids\n  }\n\n  pub async fn get_group(&self, object_id: &Uuid) -> Option<Arc<CollabGroup>> {\n    let mut attempts = 0;\n    let max_attempts = 3;\n    let retry_delay = Duration::from_millis(100);\n\n    loop {\n      match self.group_by_object_id.try_get(object_id) {\n        TryResult::Present(group) => {\n          return Some(group.clone());\n        },\n        TryResult::Absent => return None,\n        TryResult::Locked => {\n          attempts += 1;\n          if attempts >= max_attempts {\n            warn!(\"Failed to get group after {} attempts\", attempts);\n            // Give up after exceeding the max attempts\n            return None;\n          }\n          sleep(retry_delay).await;\n        },\n      }\n    }\n  }\n\n  /// Get a mutable reference to the group by object_id.\n  /// may deadlock when holding the RefMut and trying to read group_by_object_id.\n  pub(crate) async fn get_mut_group(\n    &self,\n    object_id: &Uuid,\n  ) -> Option<RefMut<Uuid, Arc<CollabGroup>>> {\n    let mut attempts = 0;\n    let max_attempts = 3;\n    let retry_delay = Duration::from_millis(300);\n\n    loop {\n      match self.group_by_object_id.try_get_mut(object_id) {\n        TryResult::Present(group) => return Some(group),\n        TryResult::Absent => return None,\n        TryResult::Locked => {\n          attempts += 1;\n          if attempts >= max_attempts {\n            warn!(\"Failed to get mut group after {} attempts\", attempts);\n            // Give up after exceeding the max attempts\n            return None;\n          }\n          sleep(retry_delay).await;\n        },\n      }\n    }\n  }\n\n  pub(crate) fn insert_group(&self, object_id: Uuid, group: CollabGroup) {\n    self.group_by_object_id.insert(object_id, group.into());\n    self.metrics_calculate.opening_collab_count.inc();\n  }\n\n  pub(crate) fn contains_group(&self, object_id: &Uuid) -> bool {\n    if let Some(group) = self.group_by_object_id.get(object_id) {\n      let cancelled = group.is_cancelled();\n      !cancelled\n    } else {\n      false\n    }\n  }\n\n  pub(crate) fn remove_group(&self, object_id: &Uuid) {\n    let group_not_found = self.group_by_object_id.remove(object_id).is_none();\n    if group_not_found {\n      // Log error if the group doesn't exist\n      error!(\"Group for object_id:{} not found\", object_id);\n    }\n\n    self\n      .metrics_calculate\n      .opening_collab_count\n      .set(self.group_by_object_id.len() as i64);\n  }\n  pub(crate) fn insert_user(\n    &self,\n    user: &RealtimeUser,\n    object_id: Uuid,\n  ) -> Result<(), RealtimeError> {\n    let editing = Editing { object_id };\n\n    let entry = self.editing_by_user.entry(user.clone());\n    match entry {\n      dashmap::mapref::entry::Entry::Occupied(_) => {},\n      dashmap::mapref::entry::Entry::Vacant(_) => {\n        self.metrics_calculate.num_of_editing_users.inc();\n      },\n    }\n\n    entry.or_default().insert(editing);\n    Ok(())\n  }\n\n  pub(crate) fn remove_user(&self, user: &RealtimeUser) {\n    let entry = self.editing_by_user.remove(user);\n    if entry.is_some() {\n      self.metrics_calculate.num_of_editing_users.dec();\n    }\n    if let Some(editing_objects) = entry.map(|(_, e)| e) {\n      for editing in editing_objects {\n        match self.group_by_object_id.try_get(&editing.object_id) {\n          TryResult::Present(group) => {\n            group.remove_user(user);\n\n            if cfg!(debug_assertions) {\n              event!(\n                tracing::Level::TRACE,\n                \"{}: current group member: {}\",\n                &editing.object_id,\n                group.user_count(),\n              );\n            }\n          },\n          TryResult::Absent => {},\n          TryResult::Locked => {\n            error!(\n              \"Failed to get the group:{}. cause by lock issue\",\n              editing.object_id\n            );\n          },\n        }\n      }\n    }\n  }\n\n  pub fn contains_user(&self, object_id: &Uuid, user: &RealtimeUser) -> bool {\n    match self.group_by_object_id.try_get(object_id) {\n      TryResult::Present(entry) => entry.value().contains_user(user),\n      TryResult::Absent => false,\n      TryResult::Locked => {\n        error!(\"Failed to get the group:{}. cause by lock issue\", object_id);\n        false\n      },\n    }\n  }\n}\n\n#[derive(Debug, Hash, PartialEq, Eq, Clone)]\nstruct Editing {\n  pub object_id: Uuid,\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/lib.rs",
    "content": "pub mod actix_ws;\nmod client;\npub mod collab;\npub mod compression;\npub mod config;\npub mod connect_state;\npub mod error;\npub mod group;\npub mod metrics;\nmod permission;\nmod rt_server;\nmod util;\npub mod ws2;\n\npub use metrics::*;\npub use permission::*;\npub use rt_server::*;\n\npub use client::client_msg_router::RealtimeClientWebsocketSink;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/metrics.rs",
    "content": "use chrono::Utc;\nuse prometheus_client::metrics::counter::Counter;\nuse prometheus_client::metrics::gauge::Gauge;\nuse prometheus_client::metrics::histogram::Histogram;\nuse prometheus_client::registry::Registry;\n\n#[derive(Clone)]\npub struct CollabRealtimeMetrics {\n  pub(crate) connected_users: Gauge,\n  pub(crate) opening_collab_count: Gauge,\n  pub(crate) num_of_editing_users: Gauge,\n  /// Number of times a compact state collab load has been done.\n  pub(crate) load_collab_count: Gauge,\n  /// Number of times a full state collab (with history) load has been done.\n  pub(crate) load_full_collab_count: Gauge,\n  /// The number of apply update\n  pub(crate) apply_update_count: Gauge,\n  /// The number of apply update failed\n  pub(crate) apply_update_failed_count: Gauge,\n  /// How long it takes to load a collab (from snapshot and updates combined).\n  pub(crate) load_collab_time: Histogram,\n  /// How big is the collab (no history, after applying all updates).\n  pub(crate) collab_size: Histogram,\n  /// How big is the collab (with history, after applying all updates).\n  pub(crate) full_collab_size: Histogram,\n  /// How long does it take since collab update is send to a stream to be read from it.\n  pub(crate) collab_stream_latency: Histogram,\n}\n\nimpl CollabRealtimeMetrics {\n  fn new() -> Self {\n    Self {\n      connected_users: Gauge::default(),\n      opening_collab_count: Gauge::default(),\n      num_of_editing_users: Gauge::default(),\n      apply_update_count: Default::default(),\n      apply_update_failed_count: Default::default(),\n\n      // when it comes to histograms we organize them by buckets or specific sizes - since our\n      // prometheus client doesn't support Summary type, we use Histogram type instead\n\n      // time spent on loading collab in milliseconds: 1ms, 5ms, 15ms, 30ms, 100ms, 200ms, 500ms, 1s\n      load_collab_time: Histogram::new(\n        [1.0, 5.0, 15.0, 30.0, 100.0, 200.0, 500.0, 1000.0].into_iter(),\n      ),\n      // collab size in bytes: 128B, 512B, 1KB, 64KB, 512KB, 1MB, 5MB, 10MB\n      collab_size: Histogram::new(\n        [\n          128.0, 512.0, 1024.0, 65536.0, 524288.0, 1048576.0, 5242880.0, 10485760.0,\n        ]\n        .into_iter(),\n      ),\n      // collab size in bytes: 128B, 512B, 1KB, 64KB, 512KB, 1MB, 5MB, 10MB\n      full_collab_size: Histogram::new(\n        [\n          128.0, 512.0, 1024.0, 65536.0, 524288.0, 1048576.0, 5242880.0, 10485760.0,\n        ]\n        .into_iter(),\n      ),\n      // collab update xadd-to-xread latency: 5ms, 50ms, 100ms, 500ms, 1s, 5s, 10s, 30s, 60s\n      collab_stream_latency: Histogram::new(\n        [\n          5.0, 50.0, 100.0, 500.0, 1000.0, 5000.0, 10000.0, 30000.0, 60000.0,\n        ]\n        .into_iter(),\n      ),\n      load_collab_count: Default::default(),\n      load_full_collab_count: Default::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::new();\n    let realtime_registry = registry.sub_registry_with_prefix(\"realtime\");\n    realtime_registry.register(\n      \"connected_users\",\n      \"number of connected users\",\n      metrics.connected_users.clone(),\n    );\n    realtime_registry.register(\n      \"opening_collab_count\",\n      \"number of opening collabs\",\n      metrics.opening_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"editing_users_count\",\n      \"number of editing users\",\n      metrics.num_of_editing_users.clone(),\n    );\n    realtime_registry.register(\n      \"apply_update_count\",\n      \"number of apply update\",\n      metrics.apply_update_count.clone(),\n    );\n    realtime_registry.register(\n      \"apply_update_failed_count\",\n      \"number of apply update failed\",\n      metrics.apply_update_failed_count.clone(),\n    );\n    realtime_registry.register(\n      \"load_collab_time\",\n      \"time spent on loading collab in milliseconds\",\n      metrics.load_collab_time.clone(),\n    );\n    realtime_registry.register(\n      \"collab_size\",\n      \"size of compact collab in bytes\",\n      metrics.collab_size.clone(),\n    );\n    realtime_registry.register(\n      \"full_collab_size\",\n      \"size of full collab in bytes\",\n      metrics.full_collab_size.clone(),\n    );\n    realtime_registry.register(\n      \"load_collab_count\",\n      \"number of collab loads (no history)\",\n      metrics.load_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"load_full_collab_count\",\n      \"number of collab loads (with history)\",\n      metrics.load_full_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"collab_stream_latency\",\n      \"latency since collab update is send to a stream to be read from it\",\n      metrics.collab_stream_latency.clone(),\n    );\n    metrics\n  }\n\n  pub fn observe_collab_stream_latency(&self, message_id_timestamp: u64) {\n    let now = Utc::now().timestamp_millis() as u64;\n    if now > message_id_timestamp {\n      self\n        .collab_stream_latency\n        .observe((now - message_id_timestamp) as f64);\n    }\n  }\n}\n\n#[derive(Clone)]\npub struct CollabMetrics {\n  pub write_snapshot: Counter,\n  pub write_snapshot_failures: Counter,\n  pub read_snapshot: Counter,\n  pub pg_write_collab_count: Counter,\n  pub s3_write_collab_count: Counter,\n  pub redis_write_collab_count: Counter,\n  pub pg_read_collab_count: Counter,\n  pub s3_read_collab_count: Counter,\n  pub redis_read_collab_count: Counter,\n  pub success_queue_collab_count: Counter,\n  pg_tx_collab_millis: Histogram,\n  /// Duration of workspace snapshot operations in milliseconds\n  pub snapshot_duration: Histogram,\n  /// Number of updates processed in each workspace snapshot\n  pub snapshot_updates_count: Histogram,\n}\n\nimpl CollabMetrics {\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::default();\n    let realtime_registry = registry.sub_registry_with_prefix(\"collab\");\n    realtime_registry.register(\n      \"write_snapshot\",\n      \"snapshot write attempts counter\",\n      metrics.write_snapshot.clone(),\n    );\n    realtime_registry.register(\n      \"write_snapshot_failures\",\n      \"counter for failed attempts to write a snapshot\",\n      metrics.write_snapshot_failures.clone(),\n    );\n    realtime_registry.register(\n      \"read_snapshot\",\n      \"snapshot read counter\",\n      metrics.read_snapshot.clone(),\n    );\n    realtime_registry.register(\n      \"pg_write_collab_count\",\n      \"success write collab to Postgres\",\n      metrics.pg_write_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"s3_write_collab_count\",\n      \"success write collab to S3\",\n      metrics.s3_write_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"redis_write_collab_count\",\n      \"success write collab to Redis\",\n      metrics.redis_write_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"pg_read_collab_count\",\n      \"success read collabs from Postgres\",\n      metrics.pg_read_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"s3_read_collab_count\",\n      \"success read collabs from S3\",\n      metrics.s3_read_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"redis_read_collab_count\",\n      \"success read collabs from Redis\",\n      metrics.redis_read_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"success_queue_collab_count\",\n      \"success queue collab\",\n      metrics.success_queue_collab_count.clone(),\n    );\n    realtime_registry.register(\n      \"pg_tx_collab_millis\",\n      \"total time (in milliseconds) spend in transaction writing collab to postgres\",\n      metrics.pg_tx_collab_millis.clone(),\n    );\n    realtime_registry.register(\n      \"snapshot_duration\",\n      \"duration of workspace snapshot operations in milliseconds\",\n      metrics.snapshot_duration.clone(),\n    );\n    realtime_registry.register(\n      \"snapshot_updates_count\",\n      \"number of updates processed in each workspace snapshot\",\n      metrics.snapshot_updates_count.clone(),\n    );\n\n    metrics\n  }\n\n  pub fn observe_pg_tx(&self, duration: std::time::Duration) {\n    self\n      .pg_tx_collab_millis\n      .observe(duration.as_millis() as f64);\n  }\n\n  pub fn observe_snapshot_duration(&self, duration: std::time::Duration) {\n    self.snapshot_duration.observe(duration.as_millis() as f64);\n  }\n\n  pub fn observe_snapshot_updates_count(&self, count: usize) {\n    self.snapshot_updates_count.observe(count as f64);\n  }\n}\n\nimpl Default for CollabMetrics {\n  fn default() -> Self {\n    CollabMetrics {\n      write_snapshot: Default::default(),\n      write_snapshot_failures: Default::default(),\n      read_snapshot: Default::default(),\n      pg_write_collab_count: Default::default(),\n      s3_write_collab_count: Default::default(),\n      redis_write_collab_count: Default::default(),\n      pg_read_collab_count: Default::default(),\n      s3_read_collab_count: Default::default(),\n      redis_read_collab_count: Default::default(),\n      success_queue_collab_count: Default::default(),\n      pg_tx_collab_millis: Histogram::new(\n        [\n          100.0, 300.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0, 30000.0, 60000.0,\n        ]\n        .into_iter(),\n      ),\n      // Snapshot duration buckets: 100ms, 500ms, 1s, 5s, 10s, 30s, 60s, 120s, 300s\n      snapshot_duration: Histogram::new(\n        [\n          100.0, 500.0, 1000.0, 5000.0, 10000.0, 30000.0, 60000.0, 120000.0, 300000.0,\n        ]\n        .into_iter(),\n      ),\n      // Snapshot updates count buckets: 1, 10, 50, 100, 500, 1000, 5000, 10000, 50000\n      snapshot_updates_count: Histogram::new(\n        [\n          1.0, 10.0, 50.0, 100.0, 500.0, 1000.0, 5000.0, 10000.0, 50000.0,\n        ]\n        .into_iter(),\n      ),\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/permission.rs",
    "content": "#[derive(Debug)]\npub enum CollabUserId<'a> {\n  UserId(&'a i64),\n  UserUuid(&'a uuid::Uuid),\n}\n\nimpl<'a> From<&'a i64> for CollabUserId<'a> {\n  fn from(uid: &'a i64) -> Self {\n    CollabUserId::UserId(uid)\n  }\n}\n\nimpl<'a> From<&'a uuid::Uuid> for CollabUserId<'a> {\n  fn from(uid: &'a uuid::Uuid) -> Self {\n    CollabUserId::UserUuid(uid)\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/rt_server.rs",
    "content": "use std::sync::{Arc, Weak};\nuse std::time::Duration;\n\nuse crate::actix_ws::entities::{ClientGenerateEmbeddingMessage, ClientHttpUpdateMessage};\nuse crate::client::client_msg_router::ClientMessageRouter;\nuse crate::connect_state::ConnectState;\nuse crate::error::{CreateGroupFailedReason, RealtimeError};\nuse crate::group::cmd::{GroupCommand, GroupCommandRunner, GroupCommandSender};\nuse crate::group::manager::GroupManager;\nuse crate::{CollabRealtimeMetrics, RealtimeClientWebsocketSink};\nuse access_control::collab::RealtimeAccessControl;\nuse anyhow::{anyhow, Result};\nuse app_error::AppError;\nuse collab_rt_entity::user::{RealtimeUser, UserDevice};\nuse collab_rt_entity::MessageByObjectId;\nuse collab_stream::awareness_gossip::AwarenessGossip;\nuse collab_stream::client::CollabRedisStream;\nuse collab_stream::stream_router::StreamRouter;\nuse dashmap::mapref::entry::Entry;\nuse dashmap::DashMap;\nuse database::collab::CollabStore;\nuse indexer::scheduler::IndexerScheduler;\nuse redis::aio::ConnectionManager;\nuse tokio::sync::mpsc::Sender;\nuse tokio::time::interval;\nuse tracing::{error, trace, warn};\nuse uuid::Uuid;\nuse yrs::updates::decoder::Decode;\nuse yrs::StateVector;\n\n#[derive(Clone)]\npub struct CollaborationServer {\n  /// Keep track of all collab groups\n  group_manager: Arc<GroupManager>,\n  connect_state: ConnectState,\n  group_sender_by_object_id: Arc<DashMap<Uuid, GroupCommandSender>>,\n  #[allow(dead_code)]\n  metrics: Arc<CollabRealtimeMetrics>,\n}\n\nimpl CollaborationServer {\n  #[allow(clippy::too_many_arguments)]\n  pub async fn new(\n    storage: Arc<dyn CollabStore>,\n    access_control: Arc<dyn RealtimeAccessControl>,\n    metrics: Arc<CollabRealtimeMetrics>,\n    redis_stream_router: Arc<StreamRouter>,\n    awareness_gossip: Arc<AwarenessGossip>,\n    redis_connection_manager: ConnectionManager,\n    group_persistence_interval: Duration,\n    indexer_scheduler: Arc<IndexerScheduler>,\n  ) -> Result<Self, RealtimeError> {\n    let connect_state = ConnectState::new();\n    let collab_stream = CollabRedisStream::new_with_connection_manager(\n      redis_connection_manager,\n      redis_stream_router,\n      awareness_gossip,\n    );\n    let group_manager = Arc::new(\n      GroupManager::new(\n        storage.clone(),\n        access_control.clone(),\n        metrics.clone(),\n        collab_stream,\n        group_persistence_interval,\n        indexer_scheduler.clone(),\n      )\n      .await?,\n    );\n    let group_sender_by_object_id: Arc<DashMap<_, GroupCommandSender>> =\n      Arc::new(Default::default());\n\n    spawn_period_check_inactive_group(Arc::downgrade(&group_manager), &group_sender_by_object_id);\n\n    Ok(Self {\n      group_manager,\n      connect_state,\n      group_sender_by_object_id,\n      metrics,\n    })\n  }\n\n  /// Handles a new user connection, replacing any existing connection for the same user.\n  ///\n  /// - Creates a new client stream for the connected user.\n  /// - Replaces any existing user connection with the new one, signaling the old connection\n  ///   if it's replaced.\n  /// - Removes the old user connection from all collaboration groups.\n  ///\n  pub fn handle_new_connection(\n    &self,\n    connected_user: RealtimeUser,\n    conn_sink: impl RealtimeClientWebsocketSink,\n  ) -> Result<(), RealtimeError> {\n    let new_client_router = ClientMessageRouter::new(conn_sink);\n    if let Some(old_user) = self\n      .connect_state\n      .handle_user_connect(connected_user, new_client_router)\n    {\n      // Remove the old user from all collaboration groups.\n      trace!(\"[realtime] remove old user: {}\", old_user);\n      self.group_manager.remove_user(&old_user);\n    }\n    self\n      .metrics\n      .connected_users\n      .set(self.connect_state.number_of_connected_users() as i64);\n    Ok(())\n  }\n\n  /// Handles a user's disconnection from the collaboration server.\n  ///\n  /// Steps:\n  /// 1. Checks if the disconnecting user's session matches the stored session.\n  ///    - If yes, proceeds with removal.\n  ///    - If not, exits without action.\n  /// 2. Removes the user from collaboration groups and client streams.\n  pub fn handle_disconnect(&self, disconnect_user: RealtimeUser) -> Result<(), RealtimeError> {\n    trace!(\"[realtime]: disconnect => {}\", disconnect_user);\n    let was_removed = self.connect_state.handle_user_disconnect(&disconnect_user);\n    if was_removed.is_some() {\n      self\n        .metrics\n        .connected_users\n        .set(self.connect_state.number_of_connected_users() as i64);\n\n      self.group_manager.remove_user(&disconnect_user);\n    }\n\n    Ok(())\n  }\n\n  #[inline]\n  pub fn handle_client_message(\n    &self,\n    user: RealtimeUser,\n    message_by_oid: MessageByObjectId,\n  ) -> Result<(), RealtimeError> {\n    for (object_id, collab_messages) in message_by_oid.into_inner() {\n      let object_id = Uuid::parse_str(&object_id)?;\n      let group_cmd_sender = self.create_group_if_not_exist(object_id);\n      let cloned_user = user.clone();\n      // Create a new task to send a message to the group command runner without waiting for the\n      // result. This approach is used to prevent potential issues with the actor's mailbox in\n      // single-threaded runtimes (like actix-web actors). By spawning a task, the actor can\n      // immediately proceed to process the next message.\n      tokio::spawn(async move {\n        let (tx, rx) = tokio::sync::oneshot::channel();\n        match group_cmd_sender\n          .send(GroupCommand::HandleClientCollabMessage {\n            user: cloned_user,\n            object_id,\n            collab_messages,\n            ret: tx,\n          })\n          .await\n        {\n          Ok(_) => {\n            if let Ok(Err(err)) = rx.await {\n              if !matches!(\n                err,\n                RealtimeError::CreateGroupFailed(\n                  CreateGroupFailedReason::CollabWorkspaceIdNotMatch { .. }\n                )\n              ) {\n                error!(\"Handle client collab message fail: {}\", err);\n              }\n            }\n          },\n          Err(err) => {\n            // it should not happen. Because the receiver is always running before acquiring the sender.\n            // Otherwise, the GroupCommandRunner might not be ready to handle the message.\n            error!(\"Send message to group fail: {}\", err);\n          },\n        }\n      });\n    }\n\n    Ok(())\n  }\n\n  pub fn handle_client_http_update(\n    &self,\n    message: ClientHttpUpdateMessage,\n  ) -> Result<(), RealtimeError> {\n    let group_cmd_sender = self.create_group_if_not_exist(message.object_id);\n    tokio::spawn(async move {\n      let object_id = message.object_id;\n      let return_tx = message.return_tx;\n\n      // Helper closure to handle error responses and logging\n      let handle_result = |app_error: AppError,\n                           return_tx: Option<\n        tokio::sync::oneshot::Sender<Result<Option<Vec<u8>>, AppError>>,\n      >| {\n        if let Some(tx) = return_tx {\n          let _ = tx.send(Err(app_error));\n        } else {\n          error!(\"{}\", app_error);\n        }\n      };\n\n      let (tx, rx) = tokio::sync::oneshot::channel();\n      let result = group_cmd_sender\n        .send(GroupCommand::HandleClientHttpUpdate {\n          user: message.user,\n          workspace_id: message.workspace_id,\n          object_id: message.object_id,\n          update: message.update,\n          collab_type: message.collab_type,\n          ret: tx,\n        })\n        .await;\n\n      if let Err(err) = result {\n        handle_result(\n          AppError::Internal(anyhow!(\"send update to group fail: {}\", err)),\n          return_tx,\n        );\n        return;\n      }\n\n      match rx.await {\n        Ok(Ok(())) => {\n          if message.state_vector.is_some() && return_tx.is_none() {\n            warn!(\n              \"state_vector is not None, but return_tx is None, object_id: {}\",\n              object_id\n            );\n          }\n\n          if let Some(return_rx) = return_tx {\n            if let Some(state_vector) = message\n              .state_vector\n              .and_then(|data| StateVector::decode_v1(&data).ok())\n            {\n              let (tx, rx) = tokio::sync::oneshot::channel();\n              let _ = group_cmd_sender\n                .send(GroupCommand::CalculateMissingUpdate {\n                  object_id,\n                  state_vector,\n                  ret: tx,\n                })\n                .await;\n\n              match rx.await {\n                Ok(missing_update_result) => {\n                  let _ = group_cmd_sender\n                    .send(GroupCommand::GenerateCollabEmbedding { object_id })\n                    .await;\n\n                  let result = missing_update_result\n                    .map_err(|err| {\n                      AppError::Internal(anyhow!(\"fail to calculate missing update: {}\", err))\n                    })\n                    .map(Some);\n                  let _ = return_rx.send(result);\n                },\n                Err(err) => {\n                  let _ = return_rx.send(Err(AppError::Internal(anyhow!(\n                    \"fail to calculate missing update: {}\",\n                    err\n                  ))));\n                },\n              }\n            } else {\n              let _ = return_rx.send(Ok(None));\n            }\n          }\n        },\n        Ok(Err(err)) => {\n          handle_result(\n            AppError::Internal(anyhow!(\"apply http update to group fail: {}\", err)),\n            return_tx,\n          );\n        },\n        Err(err) => {\n          handle_result(\n            AppError::Internal(anyhow!(\"fail to receive applied result: {}\", err)),\n            return_tx,\n          );\n        },\n      }\n    });\n\n    Ok(())\n  }\n\n  #[inline]\n  fn create_group_if_not_exist(&self, object_id: Uuid) -> Sender<GroupCommand> {\n    let old_sender = self\n      .group_sender_by_object_id\n      .get(&object_id)\n      .map(|entry| entry.value().clone());\n\n    let sender = match old_sender {\n      Some(sender) => sender,\n      None => match self.group_sender_by_object_id.entry(object_id) {\n        Entry::Occupied(entry) => entry.get().clone(),\n        Entry::Vacant(entry) => {\n          let (new_sender, recv) = tokio::sync::mpsc::channel(2000);\n          let runner = GroupCommandRunner {\n            group_manager: self.group_manager.clone(),\n            msg_router_by_user: self.connect_state.client_message_routers.clone(),\n            recv: Some(recv),\n          };\n\n          let object_id = *entry.key();\n          tokio::spawn(runner.run(object_id));\n          entry.insert(new_sender.clone());\n          new_sender\n        },\n      },\n    };\n    sender\n  }\n\n  #[inline]\n  pub fn handle_client_generate_embedding_request(\n    &self,\n    message: ClientGenerateEmbeddingMessage,\n  ) -> Result<(), RealtimeError> {\n    let group_cmd_sender = self.create_group_if_not_exist(message.object_id);\n    tokio::spawn(async move {\n      let result = group_cmd_sender\n        .send(GroupCommand::GenerateCollabEmbedding {\n          object_id: message.object_id,\n        })\n        .await;\n\n      if let Err(err) = result {\n        if let Some(return_tx) = message.return_tx {\n          let _ = return_tx.send(Err(AppError::Internal(anyhow!(\n            \"send generate embedding to group fail: {}\",\n            err\n          ))));\n        } else {\n          error!(\"send generate embedding to group fail: {}\", err);\n        }\n      }\n    });\n    Ok(())\n  }\n\n  pub fn get_user_by_device(&self, user_device: &UserDevice) -> Option<RealtimeUser> {\n    self.connect_state.get_user_by_device(user_device)\n  }\n}\n\nfn spawn_period_check_inactive_group(\n  weak_groups: Weak<GroupManager>,\n  group_sender_by_object_id: &Arc<DashMap<Uuid, GroupCommandSender>>,\n) {\n  let mut interval = interval(Duration::from_secs(20));\n  let cloned_group_sender_by_object_id = group_sender_by_object_id.clone();\n  tokio::spawn(async move {\n    // when appflowy-collaborate start, wait for 60 seconds to start the check. Since no groups will\n    // be inactive in the first 60 seconds.\n    tokio::time::sleep(Duration::from_secs(60)).await;\n\n    loop {\n      interval.tick().await;\n      if let Some(groups) = weak_groups.upgrade() {\n        let inactive_group_ids = groups.get_inactive_groups();\n        for id in inactive_group_ids {\n          cloned_group_sender_by_object_id.remove(&id);\n        }\n      } else {\n        break;\n      }\n    }\n  });\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/snapshot/mod.rs",
    "content": "mod snapshot_control;\n\npub use snapshot_control::*;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/snapshot/snapshot_control.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse chrono::{DateTime, Utc};\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse sqlx::PgPool;\nuse tracing::debug;\nuse uuid::Uuid;\nuse validator::Validate;\n\nuse app_error::AppError;\nuse database::collab::{\n  get_all_collab_snapshot_meta, select_snapshot, AppResult, COLLAB_SNAPSHOT_LIMIT,\n};\nuse database::file::s3_client_impl::AwsS3BucketClientImpl;\nuse database::file::{BucketClient, ResponseBlob};\nuse database_entity::dto::{\n  AFSnapshotMeta, AFSnapshotMetas, InsertSnapshotParams, SnapshotData, ZSTD_COMPRESSION_LEVEL,\n};\n\nuse crate::metrics::CollabMetrics;\n\npub const SNAPSHOT_TICK_INTERVAL: Duration = Duration::from_secs(2);\n\nfn collab_snapshot_key(workspace_id: &Uuid, object_id: &Uuid, snapshot_id: i64) -> String {\n  let snapshot_id = u64::MAX - snapshot_id as u64;\n  format!(\n    \"collabs/{}/{}/snapshot_{:16x}.v1.zstd\",\n    workspace_id, object_id, snapshot_id\n  )\n}\n\nfn collab_snapshot_prefix(workspace_id: &Uuid, object_id: &Uuid) -> String {\n  format!(\"collabs/{}/{}/snapshot_\", workspace_id, object_id)\n}\n\nfn get_timestamp(object_key: &str) -> Option<DateTime<Utc>> {\n  let (_, right) = object_key.rsplit_once('/')?;\n  let trimmed = right\n    .trim_start_matches(\"snapshot_\")\n    .trim_end_matches(\".v1.zstd\");\n  let snapshot_id = u64::from_str_radix(trimmed, 16).ok()?;\n  let snapshot_id = u64::MAX - snapshot_id;\n  DateTime::from_timestamp_millis(snapshot_id as i64)\n}\n\nfn get_meta(objct_key: String) -> Option<AFSnapshotMeta> {\n  let (left, right) = objct_key.rsplit_once('/')?;\n  let (_, object_id) = left.rsplit_once('/')?;\n  let trimmed = right\n    .trim_start_matches(\"snapshot_\")\n    .trim_end_matches(\".v1.zstd\");\n  let snapshot_id = u64::from_str_radix(trimmed, 16).ok()?;\n  let snapshot_id = u64::MAX - snapshot_id;\n  Some(AFSnapshotMeta {\n    snapshot_id: snapshot_id as i64,\n    object_id: object_id.to_string(),\n    created_at: DateTime::from_timestamp_millis(snapshot_id as i64)?,\n  })\n}\n\n#[derive(Clone)]\npub struct SnapshotControl {\n  pg_pool: PgPool,\n  s3: AwsS3BucketClientImpl,\n  collab_metrics: Arc<CollabMetrics>,\n}\n\nimpl SnapshotControl {\n  pub async fn new(\n    pg_pool: PgPool,\n    s3: AwsS3BucketClientImpl,\n    collab_metrics: Arc<CollabMetrics>,\n  ) -> Self {\n    Self {\n      pg_pool,\n      s3,\n      collab_metrics,\n    }\n  }\n\n  pub async fn create_snapshot(&self, params: InsertSnapshotParams) -> AppResult<AFSnapshotMeta> {\n    params.validate()?;\n\n    debug!(\"create snapshot for object:{}\", params.object_id);\n    self.collab_metrics.write_snapshot.inc();\n\n    let timestamp = Utc::now();\n    let snapshot_id = timestamp.timestamp_millis();\n    let key = collab_snapshot_key(&params.workspace_id, &params.object_id, snapshot_id);\n    let compressed = zstd::encode_all(params.doc_state.as_ref(), ZSTD_COMPRESSION_LEVEL)?;\n    if let Err(err) = self.s3.put_blob(&key, compressed.into(), None).await {\n      self.collab_metrics.write_snapshot_failures.inc();\n      return Err(err);\n    }\n\n    // drop old snapshots if exceeds limit\n    let list = self\n      .s3\n      .list_dir(\n        &collab_snapshot_prefix(&params.workspace_id, &params.object_id),\n        100,\n      )\n      .await?;\n\n    if list.len() > COLLAB_SNAPSHOT_LIMIT as usize {\n      debug!(\n        \"drop {} snapshots for `{}`\",\n        list.len() - COLLAB_SNAPSHOT_LIMIT as usize,\n        params.object_id\n      );\n      let trimmed: Vec<_> = list\n        .into_iter()\n        .skip(COLLAB_SNAPSHOT_LIMIT as usize)\n        .collect();\n\n      self.s3.delete_blobs(trimmed).await?;\n    }\n\n    Ok(AFSnapshotMeta {\n      snapshot_id,\n      object_id: params.object_id.to_string(),\n      created_at: timestamp,\n    })\n  }\n\n  pub async fn get_collab_snapshot(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    snapshot_id: &i64,\n  ) -> AppResult<SnapshotData> {\n    let key = collab_snapshot_key(&workspace_id, &object_id, *snapshot_id);\n    match self.s3.get_blob(&key).await {\n      Ok(resp) => {\n        self.collab_metrics.read_snapshot.inc();\n        let decompressed = zstd::decode_all(&*resp.to_blob())?;\n        let encoded_collab = EncodedCollab {\n          state_vector: Default::default(),\n          doc_state: decompressed.into(),\n          version: EncoderVersion::V1,\n        };\n        Ok(SnapshotData {\n          object_id,\n          encoded_collab_v1: encoded_collab.encode_to_bytes()?,\n          workspace_id,\n        })\n      },\n      Err(AppError::RecordNotFound(_)) => {\n        debug!(\n          \"snapshot {} for `{}` not found in s3: fallback to postgres\",\n          snapshot_id, object_id\n        );\n        match select_snapshot(&self.pg_pool, &workspace_id, &object_id, snapshot_id).await? {\n          None => Err(AppError::RecordNotFound(format!(\n            \"Can't find the snapshot with id:{}\",\n            snapshot_id\n          ))),\n          Some(row) => Ok(SnapshotData {\n            object_id,\n            encoded_collab_v1: row.blob,\n            workspace_id,\n          }),\n        }\n      },\n      Err(err) => Err(err),\n    }\n  }\n\n  /// Returns list of snapshots for given object_id in descending order of creation time.\n  pub async fn get_collab_snapshot_list(\n    &self,\n    workspace_id: &Uuid,\n    oid: &Uuid,\n  ) -> AppResult<AFSnapshotMetas> {\n    let snapshot_prefix = collab_snapshot_prefix(workspace_id, oid);\n    let resp = self\n      .s3\n      .list_dir(&snapshot_prefix, COLLAB_SNAPSHOT_LIMIT as usize)\n      .await?;\n    if resp.is_empty() {\n      let metas = get_all_collab_snapshot_meta(&self.pg_pool, oid).await?;\n      Ok(metas)\n    } else {\n      let metas: Vec<_> = resp.into_iter().filter_map(get_meta).collect();\n      Ok(AFSnapshotMetas(metas))\n    }\n  }\n\n  pub async fn get_snapshot(\n    &self,\n    workspace_id: Uuid,\n    object_id: Uuid,\n    snapshot_id: &i64,\n  ) -> Result<SnapshotData, AppError> {\n    self\n      .get_collab_snapshot(workspace_id, object_id, snapshot_id)\n      .await\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/util/channel_ext.rs",
    "content": "use crate::error::RealtimeError;\nuse futures_util::Sink;\nuse std::fmt::Debug;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\n#[derive(Clone)]\npub struct UnboundedSenderSink<T>(pub tokio::sync::mpsc::UnboundedSender<T>);\n\nimpl<T> UnboundedSenderSink<T> {\n  pub fn new(tx: tokio::sync::mpsc::UnboundedSender<T>) -> Self {\n    Self(tx)\n  }\n}\n\nimpl<T> Sink<T> for UnboundedSenderSink<T>\nwhere\n  T: Send + Sync + 'static + Debug,\n{\n  type Error = RealtimeError;\n\n  fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    // An unbounded channel can always accept messages without blocking, so we always return Ready.\n    Poll::Ready(Ok(()))\n  }\n\n  fn start_send(self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> {\n    let _ = self.0.send(item);\n    Ok(())\n  }\n\n  fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    // There is no buffering in an unbounded channel, so we always return Ready.\n    Poll::Ready(Ok(()))\n  }\n\n  fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {\n    // An unbounded channel is closed by dropping the sender, so we don't need to do anything here.\n    Poll::Ready(Ok(()))\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/util/mod.rs",
    "content": "pub mod channel_ext;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/ws2/actors/mod.rs",
    "content": "mod server;\nmod session;\nmod workspace;\n\npub use server::*;\npub use session::*;\npub use workspace::*;\n"
  },
  {
    "path": "services/appflowy-collaborate/src/ws2/actors/server.rs",
    "content": "use super::session::{WsInput, WsSession};\nuse super::workspace::{Terminate, Workspace};\nuse crate::collab::collab_manager::CollabManager;\nuse crate::collab::snapshot_scheduler::SnapshotScheduler;\nuse crate::ws2::{BulkPermissionUpdate, PermissionUpdate};\nuse actix::{Actor, Addr, Arbiter, AsyncContext, Handler, Recipient};\nuse app_error::AppError;\nuse appflowy_proto::{ObjectId, Rid, ServerMessage, WorkspaceId};\nuse collab::core::origin::CollabOrigin;\nuse collab_entity::CollabType;\nuse collab_folder::Folder;\nuse database::collab::AppResult;\nuse std::collections::HashMap;\nuse std::fmt::Display;\nuse std::sync::atomic::AtomicU64;\nuse std::sync::Arc;\nuse tracing::info;\nuse yrs::block::ClientID;\n\npub struct WsServer {\n  manager: Arc<CollabManager>,\n  snapshot_scheduler: SnapshotScheduler,\n  workspaces: HashMap<WorkspaceId, Addr<Workspace>>,\n  arbiter_pool: ArbiterPool,\n}\n\nimpl WsServer {\n  pub fn new(manager: Arc<CollabManager>) -> Self {\n    let snapshot_scheduler = SnapshotScheduler::new(manager.clone());\n    let arbiter_pool = ArbiterPool::default();\n    Self {\n      manager,\n      snapshot_scheduler,\n      workspaces: HashMap::new(),\n      arbiter_pool,\n    }\n  }\n\n  fn init_workspace(\n    server: Recipient<Terminate>,\n    workspace_id: WorkspaceId,\n    manager: Arc<CollabManager>,\n    snapshot_scheduler: SnapshotScheduler,\n    pool: &ArbiterPool,\n  ) -> Addr<Workspace> {\n    let arbiter = pool.next();\n    Workspace::start_in_arbiter(&arbiter.handle(), move |_ctx| {\n      Workspace::new(server, workspace_id, manager, snapshot_scheduler)\n    })\n  }\n}\n\nimpl Actor for WsServer {\n  type Context = actix::Context<Self>;\n}\n\nimpl Handler<Join> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: Join, ctx: &mut Self::Context) -> Self::Result {\n    let server = ctx.address().recipient();\n    let workspace = self.workspaces.entry(msg.workspace_id).or_insert_with(|| {\n      Self::init_workspace(\n        server,\n        msg.workspace_id,\n        self.manager.clone(),\n        self.snapshot_scheduler.clone(),\n        &self.arbiter_pool,\n      )\n    });\n    info!(\"{} joined\", msg);\n    workspace.do_send(msg);\n  }\n}\n\nimpl Handler<Leave> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: Leave, _ctx: &mut Self::Context) -> Self::Result {\n    if let Some(workspace) = self.workspaces.get(&msg.workspace_id) {\n      workspace.do_send(msg);\n    }\n  }\n}\n\nimpl Handler<WsInput> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: WsInput, ctx: &mut Self::Context) -> Self::Result {\n    let server = ctx.address().recipient();\n    let workspace = self.workspaces.entry(msg.workspace_id).or_insert_with(|| {\n      Self::init_workspace(\n        server,\n        msg.workspace_id,\n        self.manager.clone(),\n        self.snapshot_scheduler.clone(),\n        &self.arbiter_pool,\n      )\n    });\n    workspace.do_send(msg);\n  }\n}\n\nimpl Handler<Terminate> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: Terminate, _ctx: &mut Self::Context) -> Self::Result {\n    self.workspaces.remove(&msg.workspace_id);\n    tracing::debug!(\"workspace {} closed\", msg.workspace_id);\n  }\n}\n\nimpl Handler<PublishUpdate> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: PublishUpdate, ctx: &mut Self::Context) -> Self::Result {\n    let server = ctx.address().recipient();\n    let workspace = self.workspaces.entry(msg.workspace_id).or_insert_with(|| {\n      Self::init_workspace(\n        server,\n        msg.workspace_id,\n        self.manager.clone(),\n        self.snapshot_scheduler.clone(),\n        &self.arbiter_pool,\n      )\n    });\n    workspace.do_send(msg);\n  }\n}\n\nimpl Handler<WorkspaceFolder> for WsServer {\n  type Result = ();\n\n  fn handle(&mut self, msg: WorkspaceFolder, ctx: &mut Self::Context) -> Self::Result {\n    let server = ctx.address().recipient();\n    let workspace = self.workspaces.entry(msg.workspace_id).or_insert_with(|| {\n      Self::init_workspace(\n        server,\n        msg.workspace_id,\n        self.manager.clone(),\n        self.snapshot_scheduler.clone(),\n        &self.arbiter_pool,\n      )\n    });\n    workspace.do_send(msg);\n  }\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct Join {\n  pub uid: i64,\n  /// Current client session identifier.\n  pub session_id: ClientID,\n  pub collab_origin: CollabOrigin,\n  pub last_message_id: Option<Rid>,\n  /// Actix WebSocket session actor address.\n  pub addr: Addr<WsSession>,\n  /// Workspace to join.\n  pub workspace_id: WorkspaceId,\n}\n\nimpl Display for Join {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"Join(uid: {}, session_id: {}, workspace_id: {})\",\n      self.uid, self.session_id, self.workspace_id\n    )\n  }\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct Leave {\n  /// Current client session identifier.\n  pub session_id: ClientID,\n  /// Workspace to leave.\n  pub workspace_id: WorkspaceId,\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct WsOutput {\n  pub message: ServerMessage,\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct UpdateUserPermissions {\n  pub uid: i64,\n  pub updates: Vec<PermissionUpdate>,\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct BroadcastPermissionChanges {\n  pub changes: BulkPermissionUpdate,\n  pub exclude_uid: Option<i64>, // Don't send to the user who made the change\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct PublishUpdate {\n  pub workspace_id: WorkspaceId,\n  pub object_id: ObjectId,\n  pub collab_type: CollabType,\n  pub sender: CollabOrigin,\n  pub update_v1: Vec<u8>,\n  pub ack: tokio::sync::oneshot::Sender<anyhow::Result<Rid>>,\n}\n\n#[async_trait::async_trait]\npub trait CollabUpdatePublisher {\n  async fn publish_update(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    sender: &CollabOrigin,\n    update_v1: Vec<u8>,\n  ) -> anyhow::Result<Rid>;\n}\n\n#[async_trait::async_trait]\nimpl CollabUpdatePublisher for Addr<WsServer> {\n  async fn publish_update(\n    &self,\n    workspace_id: WorkspaceId,\n    object_id: ObjectId,\n    collab_type: CollabType,\n    sender: &CollabOrigin,\n    update_v1: Vec<u8>,\n  ) -> anyhow::Result<Rid> {\n    let (ack, rx) = tokio::sync::oneshot::channel();\n    self.do_send(PublishUpdate {\n      workspace_id,\n      object_id,\n      collab_type,\n      sender: sender.clone(),\n      update_v1,\n      ack,\n    });\n    rx.await?\n  }\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct WorkspaceFolder {\n  pub workspace_id: WorkspaceId,\n  pub ack: tokio::sync::oneshot::Sender<AppResult<Folder>>,\n}\n\n#[async_trait::async_trait]\npub trait WorkspaceCollabInstanceCache {\n  async fn get_folder(&self, workspace_id: WorkspaceId) -> AppResult<Folder>;\n}\n\n#[async_trait::async_trait]\nimpl WorkspaceCollabInstanceCache for Addr<WsServer> {\n  async fn get_folder(&self, workspace_id: WorkspaceId) -> AppResult<Folder> {\n    let (ack, rx) = tokio::sync::oneshot::channel();\n    self.do_send(WorkspaceFolder { workspace_id, ack });\n    rx.await.map_err(|err| AppError::Internal(err.into()))?\n  }\n}\n\npub struct ArbiterPool {\n  arbiters: Vec<Arbiter>,\n  next: AtomicU64,\n}\n\nimpl Default for ArbiterPool {\n  fn default() -> Self {\n    let parallelism = match std::thread::available_parallelism() {\n      Ok(max_cpus) => max_cpus.get(),\n      Err(_) => 4, // Fallback to a default value if unable to determine\n    };\n    Self::new(parallelism)\n  }\n}\n\nimpl ArbiterPool {\n  pub fn new(size: usize) -> Self {\n    tracing::info!(\"creating arbiter pool on {} threads\", size);\n    let mut arbiters = Vec::with_capacity(size);\n    for _ in 0..size {\n      arbiters.push(Arbiter::new());\n    }\n    Self {\n      arbiters,\n      next: AtomicU64::new(0),\n    }\n  }\n\n  pub fn next(&self) -> &Arbiter {\n    let index =\n      self.next.fetch_add(1, std::sync::atomic::Ordering::SeqCst) % (self.arbiters.len() as u64);\n    &self.arbiters[index as usize]\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/ws2/actors/session.rs",
    "content": "use super::server::{Join, Leave, WsOutput, WsServer};\nuse actix::{\n  fut, Actor, ActorContext, ActorFutureExt, Addr, AsyncContext, ContextFutureSpawner, Handler,\n  Running, StreamHandler, WrapFuture,\n};\nuse actix_http::ws::{CloseCode, CloseReason, Item, ProtocolError};\nuse actix_web_actors::ws;\nuse appflowy_proto::{ClientMessage, ObjectId, Rid, ServerMessage, UpdateFlags, WorkspaceId};\nuse bytes::{Bytes, BytesMut};\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse collab_entity::CollabType;\nuse collab_stream::model::MessageId;\nuse std::time::{Duration, Instant};\nuse tracing::error;\nuse yrs::block::ClientID;\nuse yrs::sync::AwarenessUpdate;\nuse yrs::updates::decoder::Decode;\nuse yrs::StateVector;\n\npub const HEARTBEAT: Duration = Duration::from_secs(5);\nconst CLIENT_TIMEOUT: Duration = Duration::from_secs(15);\n\n#[derive(Debug)]\npub struct SessionInfo {\n  pub client_id: ClientID,\n  pub user_id: i64,\n  pub device_id: String,\n  pub last_message_id: Option<MessageId>,\n}\n\nimpl SessionInfo {\n  pub fn new(\n    client_id: ClientID,\n    user_id: i64,\n    device_id: String,\n    last_message_id: Option<MessageId>,\n  ) -> Self {\n    Self {\n      client_id,\n      user_id,\n      device_id,\n      last_message_id,\n    }\n  }\n\n  pub fn collab_origin(&self) -> CollabOrigin {\n    CollabOrigin::Client(CollabClient::new(self.user_id, self.device_id.clone()))\n  }\n}\n\npub type ExtraMessageReceiver = tokio::sync::mpsc::Receiver<ServerMessage>;\npub struct WsSession {\n  current_workspace: WorkspaceId,\n  info: SessionInfo,\n  server: Addr<WsServer>,\n  hb: Instant,\n  buf: Option<BytesMut>,\n  extra_message_rx: Option<ExtraMessageReceiver>,\n}\n\nimpl WsSession {\n  pub fn new(\n    workspace: WorkspaceId,\n    info: SessionInfo,\n    server: Addr<WsServer>,\n    extra_message_rx: ExtraMessageReceiver,\n  ) -> Self {\n    WsSession {\n      info,\n      server,\n      current_workspace: workspace,\n      hb: Instant::now(),\n      buf: None,\n      extra_message_rx: Some(extra_message_rx),\n    }\n  }\n\n  pub fn uid(&self) -> i64 {\n    self.info.user_id\n  }\n\n  /// Unique identifier of current session.\n  fn id(&self) -> ClientID {\n    self.info.client_id\n  }\n\n  fn hb(&self, ctx: &mut ws::WebsocketContext<Self>) {\n    ctx.run_interval(HEARTBEAT, |act, ctx| {\n      if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {\n        tracing::trace!(\n          \"session `{}` failed to receive pong within {:?}\",\n          act.id(),\n          CLIENT_TIMEOUT\n        );\n        act.server.do_send(Leave {\n          session_id: act.id(),\n          workspace_id: act.current_workspace,\n        });\n        ctx.stop();\n        return;\n      }\n      ctx.ping(b\"\");\n    });\n  }\n\n  fn handle_protocol(&mut self, bytes: Bytes, ctx: &mut ws::WebsocketContext<Self>) {\n    match ClientMessage::from_bytes(&bytes) {\n      Ok(message) => {\n        tracing::trace!(\n          \"received message from session `{}`: {:#?}\",\n          self.id(),\n          message\n        );\n        let object_id = *message.object_id();\n        let message = match InputMessage::try_from(message) {\n          Ok(msg) => msg,\n          Err(err) => return ctx.close(Some(CloseReason::from((CloseCode::Invalid, err)))),\n        };\n        self.server.do_send(WsInput {\n          message,\n          workspace_id: self.current_workspace,\n          object_id,\n          client_id: self.id(),\n          sender: self.info.collab_origin(),\n        });\n      },\n      Err(err) => {\n        tracing::warn!(\"client {} send invalid message: {}\", self.id(), err);\n        ctx.close(Some(CloseReason::from((\n          CloseCode::Invalid,\n          err.to_string(),\n        ))));\n      },\n    }\n  }\n}\n\nimpl Actor for WsSession {\n  type Context = ws::WebsocketContext<Self>;\n\n  fn started(&mut self, ctx: &mut Self::Context) {\n    tracing::trace!(\"starting session `{}`\", self.id());\n    self.hb(ctx);\n\n    let recipient = ctx.address().recipient();\n    if let Some(mut external_source) = self.extra_message_rx.take() {\n      actix::spawn(async move {\n        while let Some(message) = external_source.recv().await {\n          let output = WsOutput { message };\n          let _ = recipient.send(output).await;\n        }\n      });\n    } else {\n      error!(\"extra_message_sender only take once\");\n    }\n\n    let join = Join {\n      uid: self.info.user_id,\n      session_id: self.id(),\n      collab_origin: self.info.collab_origin(),\n      addr: ctx.address(),\n      last_message_id: self.info.last_message_id.map(MessageId::into),\n      workspace_id: self.current_workspace,\n    };\n    self\n      .server\n      .send(join)\n      .into_actor(self)\n      .then(|res, act, ctx| {\n        if let Err(err) = res {\n          tracing::warn!(\"session `{}` can't join: {}\", act.id(), err);\n          ctx.stop();\n        }\n        fut::ready(())\n      })\n      .wait(ctx);\n  }\n\n  fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {\n    tracing::trace!(\"stopping session `{}`\", self.id());\n    self.server.do_send(Leave {\n      session_id: self.id(),\n      workspace_id: self.current_workspace,\n    });\n    Running::Stop\n  }\n}\n\nimpl Handler<WsOutput> for WsSession {\n  type Result = ();\n\n  fn handle(&mut self, msg: WsOutput, ctx: &mut Self::Context) {\n    tracing::trace!(\n      \"sending message through session `{}`: {:#?}\",\n      self.id(),\n      msg.message\n    );\n    if let Ok(bytes) = msg.message.into_bytes() {\n      ctx.binary(bytes);\n    };\n  }\n}\n\nimpl Handler<GetUid> for WsSession {\n  type Result = i64;\n\n  fn handle(&mut self, _msg: GetUid, _ctx: &mut Self::Context) -> Self::Result {\n    self.uid()\n  }\n}\n\nimpl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WsSession {\n  fn handle(&mut self, item: Result<ws::Message, ProtocolError>, ctx: &mut Self::Context) {\n    match item {\n      Ok(message) => {\n        self.hb = Instant::now();\n        match message {\n          ws::Message::Ping(bytes) => {\n            ctx.pong(&bytes);\n          },\n          ws::Message::Pong(_) => {},\n          ws::Message::Text(data) => ctx.text(data), // echo text messages back\n          ws::Message::Binary(bytes) => {\n            self.handle_protocol(bytes, ctx);\n          },\n          ws::Message::Continuation(item) => {\n            match item {\n              Item::FirstText(_) => ctx.close(Some(CloseReason::from((\n                CloseCode::Unsupported,\n                \"only binary content is supported\",\n              )))),\n              Item::FirstBinary(bytes) => self.buf = Some(bytes.into()),\n              Item::Continue(bytes) => {\n                if let Some(ref mut buf) = self.buf {\n                  buf.extend_from_slice(&bytes);\n                } else {\n                  ctx.close(Some(CloseReason::from((\n                    CloseCode::Protocol,\n                    \"continuation frame without initialization\",\n                  )))); // unexpected\n                }\n              },\n              Item::Last(bytes) => {\n                if let Some(mut buf) = self.buf.take() {\n                  buf.extend_from_slice(&bytes);\n                  self.handle_protocol(buf.freeze(), ctx);\n                } else {\n                  ctx.close(Some(CloseReason::from((\n                    CloseCode::Protocol,\n                    \"last frame without initialization\",\n                  )))); // unexpected\n                }\n              },\n            }\n          },\n          ws::Message::Close(reason) => {\n            tracing::trace!(\"session `{}` closed by the client: {:?}\", self.id(), reason);\n            ctx.close(reason);\n            ctx.stop();\n          },\n          _ => { /* do nothing */ },\n        }\n      },\n      Err(err) => {\n        tracing::warn!(\"session `{}` websocket protocol error: {}\", self.id(), err);\n        ctx.stop();\n      },\n    }\n  }\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct WsInput {\n  pub message: InputMessage,\n  pub workspace_id: ObjectId,\n  pub object_id: ObjectId,\n  pub sender: CollabOrigin,\n  pub client_id: ClientID,\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"i64\")]\npub struct GetUid;\n\npub enum InputMessage {\n  Manifest(CollabType, Rid, StateVector),\n  Update(CollabType, UpdateFlags, Vec<u8>),\n  AwarenessUpdate(AwarenessUpdate),\n}\n\nimpl TryFrom<ClientMessage> for InputMessage {\n  type Error = String;\n\n  fn try_from(value: ClientMessage) -> Result<Self, Self::Error> {\n    match value {\n      ClientMessage::Manifest {\n        last_message_id,\n        state_vector,\n        collab_type,\n        ..\n      } => {\n        let state_vector = StateVector::decode_v1(&state_vector).map_err(|err| err.to_string())?;\n        Ok(InputMessage::Manifest(\n          collab_type,\n          last_message_id,\n          state_vector,\n        ))\n      },\n      ClientMessage::Update {\n        flags,\n        update,\n        collab_type,\n        ..\n      } => Ok(InputMessage::Update(collab_type, flags, update)),\n      ClientMessage::AwarenessUpdate { awareness, .. } => {\n        let awareness = AwarenessUpdate::decode_v1(&awareness).map_err(|err| err.to_string())?;\n        Ok(InputMessage::AwarenessUpdate(awareness))\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/ws2/actors/workspace.rs",
    "content": "use super::server::{Join, Leave, WsOutput};\nuse super::session::{InputMessage, WsInput, WsSession};\nuse crate::collab::collab_manager::CollabManager;\nuse crate::collab::snapshot_scheduler::SnapshotScheduler;\nuse crate::ws2::{\n  BroadcastPermissionChanges, PublishUpdate, UpdateUserPermissions, WorkspaceFolder,\n};\nuse actix::ActorFutureExt;\nuse actix::{\n  fut, Actor, ActorContext, Addr, AsyncContext, AtomicResponse, Handler, Recipient,\n  ResponseActFuture, Running, SpawnHandle, StreamHandler, WrapFuture,\n};\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_proto::{AccessChangedReason, ObjectId, Rid, ServerMessage, UpdateFlags, WorkspaceId};\nuse chrono::DateTime;\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncoderVersion;\nuse collab_entity::CollabType;\nuse collab_stream::model::{AwarenessStreamUpdate, UpdateStreamMessage};\nuse futures_util::future::join_all;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::sync::RwLock;\nuse yrs::block::ClientID;\nuse yrs::updates::encoder::Encode;\nuse yrs::{StateVector, Update};\n\npub struct Workspace {\n  server: Recipient<Terminate>,\n  workspace_id: WorkspaceId,\n  last_message_id: Rid,\n  manager: Arc<CollabManager>,\n  snapshot_scheduler: SnapshotScheduler,\n  sessions_by_client_id: HashMap<ClientID, WorkspaceSessionHandle>,\n  updates_handle: Option<SpawnHandle>,\n  awareness_handle: Option<SpawnHandle>,\n  snapshot_handle: Option<SpawnHandle>,\n  termination_handle: Option<SpawnHandle>,\n  permission_cache_cleanup_handle: Option<SpawnHandle>,\n}\n\nimpl Workspace {\n  pub const SNAPSHOT_INTERVAL: Duration = Duration::from_secs(60);\n  pub const PERMISSION_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(180); // 3 minutes\n  pub const PUBLISH_COLLAB_LIMIT: usize = 500;\n\n  pub fn new(\n    server: Recipient<Terminate>,\n    workspace_id: WorkspaceId,\n    manager: Arc<CollabManager>,\n    snapshot_scheduler: SnapshotScheduler,\n  ) -> Self {\n    Self {\n      server,\n      workspace_id,\n      manager,\n      snapshot_scheduler,\n      last_message_id: Rid::default(),\n      sessions_by_client_id: HashMap::new(),\n      updates_handle: None,\n      awareness_handle: None,\n      snapshot_handle: None,\n      termination_handle: None,\n      permission_cache_cleanup_handle: None,\n    }\n  }\n\n  async fn publish_update(\n    store: Arc<CollabManager>,\n    workspace_id: WorkspaceId,\n    msg: PublishUpdate,\n  ) {\n    let result = store\n      .publish_update(\n        workspace_id,\n        msg.object_id,\n        msg.collab_type,\n        &msg.sender,\n        msg.update_v1,\n      )\n      .await;\n    let _ = msg.ack.send(result);\n  }\n\n  async fn get_folder(store: Arc<CollabManager>, msg: WorkspaceFolder) {\n    let WorkspaceFolder { workspace_id, ack } = msg;\n    let result = store.build_folder(workspace_id).await;\n    let _ = ack.send(result);\n  }\n\n  async fn hande_ws_input(store: Arc<CollabManager>, sender: WorkspaceSessionHandle, msg: WsInput) {\n    match msg.message {\n      InputMessage::Manifest(collab_type, rid, state_vector) => {\n        match store\n          .get_latest_state(\n            msg.workspace_id,\n            msg.object_id,\n            collab_type,\n            sender.collab_origin.client_user_id().unwrap(),\n            state_vector,\n          )\n          .await\n        {\n          Ok(state) => {\n            tracing::trace!(\n              \"replying to {} manifest (client msg id: {}, server msg id: {})\",\n              msg.object_id,\n              rid,\n              state.rid\n            );\n            sender.conn.do_send(WsOutput {\n              message: ServerMessage::Update {\n                object_id: msg.object_id,\n                collab_type,\n                flags: state.flags,\n                last_message_id: state.rid,\n                update: state.update,\n              },\n            });\n            tracing::trace!(\n              \"sending manifest for {}, sv:{:?}\",\n              msg.object_id,\n              state.state_vector\n            );\n            sender.conn.do_send(WsOutput {\n              message: ServerMessage::Manifest {\n                object_id: msg.object_id,\n                collab_type,\n                last_message_id: Rid::default(),\n                state_vector: state.state_vector,\n              },\n            });\n\n            //TODO: fetch all documents that have been updated since `rid` and send their manifests to the client\n          },\n          Err(AppError::RecordNotFound(_)) => {\n            tracing::trace!(\"sending manifest for new collab {}\", msg.object_id);\n            sender.conn.do_send(WsOutput {\n              message: ServerMessage::Manifest {\n                object_id: msg.object_id,\n                collab_type,\n                last_message_id: Rid::default(),\n                state_vector: StateVector::default().encode_v1(),\n              },\n            });\n          },\n          Err(AppError::RecordDeleted(_)) => {\n            tracing::trace!(\"sending manifest for new collab {}\", msg.object_id);\n            sender.conn.do_send(WsOutput {\n              message: ServerMessage::AccessChanges {\n                object_id: msg.object_id,\n                collab_type,\n                can_read: false,\n                can_write: false,\n                reason: AccessChangedReason::ObjectDeleted,\n              },\n            });\n          },\n          Err(err) => {\n            tracing::error!(\n              \"failed to resolve state of {}/{}/{}: {}\",\n              msg.workspace_id,\n              msg.client_id,\n              msg.object_id,\n              err\n            );\n          },\n        };\n      },\n      InputMessage::Update(collab_type, flags, update) => {\n        if is_empty_update(&update, &flags) {\n          tracing::trace!(\"skipping empty update {}\", msg.object_id);\n          return;\n        }\n\n        // Check if the user has permission to write to the collab\n        if sender\n          .can_write_collab(&store, &msg.object_id)\n          .await\n          .is_err()\n        {\n          tracing::trace!(\n            \"user {} lack of permission to write to collab {}\",\n            sender.uid,\n            msg.object_id,\n          );\n          return;\n        }\n\n        if let Err(err) = store\n          .publish_update(\n            msg.workspace_id,\n            msg.object_id,\n            collab_type,\n            &msg.sender,\n            update,\n          )\n          .await\n        {\n          tracing::error!(\"failed to publish update: {:?}\", err);\n        }\n      },\n      InputMessage::AwarenessUpdate(update) => {\n        if let Err(err) = store\n          .publish_awareness_update(\n            msg.workspace_id,\n            msg.object_id,\n            sender.collab_origin.clone(),\n            update,\n          )\n          .await\n        {\n          tracing::error!(\"failed to publish awareness update: {:?}\", err);\n        }\n      },\n    }\n  }\n\n  async fn publish_collabs_created_since(\n    store: Arc<CollabManager>,\n    session_handle: WorkspaceSessionHandle,\n    client_id: ClientID,\n    workspace_id: WorkspaceId,\n    last_message_id: Rid,\n    reply_to: Addr<WsSession>,\n    limit: usize,\n  ) -> Result<(), AppError> {\n    let since =\n      DateTime::from_timestamp_millis(last_message_id.timestamp as i64).ok_or_else(|| {\n        AppError::Internal(anyhow!(\n          \"last message id timestamp is invalid: {}\",\n          last_message_id\n        ))\n      })?;\n    {\n      let new_collabs = store\n        .get_collabs_created_since(workspace_id, since, limit)\n        .await?;\n      tracing::trace!(\n        \"{} collabs created in workspace {} since {}\",\n        new_collabs.len(),\n        workspace_id,\n        since\n      );\n      for collab in new_collabs {\n        if session_handle\n          .can_read_collab(&store, &collab.object_id)\n          .await\n          .is_err()\n        {\n          tracing::trace!(\n            \"user {} lack of permission. skip publish new collab {}\",\n            session_handle.uid,\n            collab.object_id,\n          );\n          continue;\n        };\n\n        // [0,0] is an empty Yrs document update encoded in v1 encoding\n        if !collab.encoded_collab.doc_state.is_empty() && *collab.encoded_collab.doc_state != [0, 0]\n        {\n          tracing::trace!(\n            \"sending new collab {} state ({} bytes)\",\n            collab.object_id,\n            collab.encoded_collab.doc_state.len()\n          );\n          let rid = match collab.updated_at {\n            None => Rid::default(),\n            Some(updated_at) => Rid::new(updated_at.timestamp_millis() as u64, 0),\n          };\n          reply_to.do_send(WsOutput {\n            message: ServerMessage::Update {\n              object_id: collab.object_id,\n              collab_type: collab.collab_type,\n              flags: match collab.encoded_collab.version {\n                EncoderVersion::V1 => UpdateFlags::Lib0v1,\n                EncoderVersion::V2 => UpdateFlags::Lib0v2,\n              },\n              last_message_id: rid,\n              update: collab.encoded_collab.doc_state,\n            },\n          });\n        }\n      }\n    }\n    let updates = store\n      .get_workspace_updates(&workspace_id, last_message_id.into())\n      .await?;\n    for update in updates {\n      tracing::trace!(\n        \"sending collab {} update ({} bytes)\",\n        update.object_id,\n        update.update.len()\n      );\n\n      if session_handle\n        .can_read_collab(&store, &update.object_id)\n        .await\n        .is_err()\n      {\n        tracing::trace!(\n          \"user {} lack of permission. skip publish collab {}, client_id: {}\",\n          session_handle.uid,\n          update.object_id,\n          client_id,\n        );\n        continue;\n      };\n      reply_to.do_send(WsOutput {\n        message: ServerMessage::Update {\n          object_id: update.object_id,\n          collab_type: update.collab_type,\n          flags: update.update_flags,\n          last_message_id: update.last_message_id,\n          update: update.update,\n        },\n      });\n    }\n\n    Ok(())\n  }\n\n  /// Schedules a termination signal for the workspace in 1min.\n  /// If there was a previous termination handle, it will be canceled.\n  fn schedule_terminate(&mut self, ctx: &mut actix::Context<Self>) {\n    if let Some(handle) = self.termination_handle.take() {\n      ctx.cancel_future(handle);\n    }\n    let handle = ctx.notify_later(\n      Terminate {\n        workspace_id: self.workspace_id,\n      },\n      std::time::Duration::from_secs(60),\n    );\n    self.termination_handle = Some(handle);\n  }\n}\n\nimpl Actor for Workspace {\n  type Context = actix::Context<Self>;\n\n  fn started(&mut self, ctx: &mut Self::Context) {\n    tracing::info!(\"initializing workspace: {}\", self.workspace_id);\n    let update_streams_key = UpdateStreamMessage::stream_key(&self.workspace_id);\n    let stream = self\n      .manager\n      .updates()\n      .observe::<UpdateStreamMessage>(update_streams_key, None);\n    self.updates_handle = Some(ctx.add_stream(stream));\n    let stream = self\n      .manager\n      .awareness()\n      .workspace_awareness_stream(&self.workspace_id);\n    self.awareness_handle = Some(ctx.add_stream(stream));\n    self.snapshot_handle = Some(ctx.notify_later(Snapshot, Self::SNAPSHOT_INTERVAL));\n    self.permission_cache_cleanup_handle = Some(ctx.notify_later(\n      CleanupPermissionCaches,\n      Self::PERMISSION_CACHE_CLEANUP_INTERVAL,\n    ));\n  }\n\n  fn stopping(&mut self, ctx: &mut Self::Context) -> Running {\n    tracing::info!(\"workspace {} stopping\", self.workspace_id);\n    self.server.do_send(Terminate {\n      workspace_id: self.workspace_id,\n    });\n    if let Some(handle) = self.updates_handle.take() {\n      ctx.cancel_future(handle);\n    }\n    if let Some(handle) = self.awareness_handle.take() {\n      ctx.cancel_future(handle);\n    }\n    if let Some(handle) = self.snapshot_handle.take() {\n      ctx.cancel_future(handle);\n\n      // when closing the workspace, we should schedule a snapshot\n      self\n        .snapshot_scheduler\n        .schedule_snapshot(self.workspace_id, self.last_message_id);\n    }\n    if let Some(handle) = self.termination_handle.take() {\n      ctx.cancel_future(handle);\n    }\n    if let Some(handle) = self.permission_cache_cleanup_handle.take() {\n      ctx.cancel_future(handle);\n    }\n    Running::Stop\n  }\n}\n\nimpl StreamHandler<anyhow::Result<UpdateStreamMessage>> for Workspace {\n  fn handle(&mut self, item: anyhow::Result<UpdateStreamMessage>, ctx: &mut Self::Context) {\n    let msg = match item {\n      Ok(msg) => msg,\n      Err(err) => {\n        tracing::error!(\n          \"failed to read update stream message for workspace {}: {:?}\",\n          self.workspace_id,\n          err\n        );\n        ctx.stop();\n        return;\n      },\n    };\n\n    let update_flags = msg.update_flags;\n    if is_empty_update(&msg.update, &update_flags) {\n      tracing::trace!(\"Receive empty update {}, skipping\", msg.object_id);\n      return;\n    }\n\n    self.last_message_id = self.last_message_id.max(msg.last_message_id);\n    let store = self.manager.clone();\n    let object_id = msg.object_id;\n    let collab_type = msg.collab_type;\n    let last_message_id = msg.last_message_id;\n    let update = msg.update.clone();\n    store.mark_as_dirty(object_id, msg.last_message_id.timestamp);\n\n    let sessions: Vec<WorkspaceSessionHandle> = self\n      .sessions_by_client_id\n      .values()\n      .filter(|s| s.collab_origin != msg.sender)\n      .cloned()\n      .collect();\n\n    ctx.spawn(\n      async move {\n        // For each session, check permission then send if allowed\n        let send_tasks = sessions.into_iter().map(|session| {\n          let store = Arc::clone(&store);\n          let update = update.clone();\n          async move {\n            if session.can_read_collab(&store, &object_id).await.is_ok() {\n              session.conn.do_send(WsOutput {\n                message: ServerMessage::Update {\n                  object_id,\n                  collab_type,\n                  flags: update_flags,\n                  last_message_id,\n                  update,\n                },\n              });\n            } else {\n              tracing::trace!(\n                \"user {} lack of permission. skip publish collab {}\",\n                session.uid,\n                object_id,\n              );\n            }\n          }\n        });\n        join_all(send_tasks).await;\n      }\n      .into_actor(self)\n      .map(|_, _, _| ()),\n    );\n  }\n}\n\nimpl StreamHandler<(ObjectId, Arc<AwarenessStreamUpdate>)> for Workspace {\n  fn handle(\n    &mut self,\n    (object_id, msg): (ObjectId, Arc<AwarenessStreamUpdate>),\n    _: &mut Self::Context,\n  ) {\n    tracing::trace!(\n      \"received awareness update for {}/{}\",\n      self.workspace_id,\n      object_id\n    );\n    for (session_id, sender) in self.sessions_by_client_id.iter() {\n      if sender.collab_origin == msg.sender {\n        continue; // skip the sender\n      }\n      tracing::trace!(\"sending awareness update to {}\", session_id);\n      sender.conn.do_send(WsOutput {\n        message: ServerMessage::AwarenessUpdate {\n          object_id,\n          collab_type: CollabType::Unknown,\n          awareness: msg.data.encode_v1().into(),\n        },\n      });\n    }\n  }\n}\n\nimpl Handler<Join> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, msg: Join, ctx: &mut Self::Context) -> Self::Result {\n    if msg.workspace_id == self.workspace_id {\n      let handle = WorkspaceSessionHandle::new(\n        msg.uid,\n        msg.workspace_id,\n        msg.collab_origin,\n        msg.addr.clone(),\n      );\n      self\n        .sessions_by_client_id\n        .insert(msg.session_id, handle.clone());\n      tracing::trace!(\n        \"attached session `{}` to workspace {}\",\n        msg.session_id,\n        msg.workspace_id\n      );\n      let store = self.manager.clone();\n      let workspace_id = self.workspace_id;\n      let session = msg.addr;\n      if let Some(last_message_id) = msg.last_message_id {\n        ctx.spawn(\n          async move {\n            if let Err(err) = Self::publish_collabs_created_since(\n              store,\n              handle,\n              msg.session_id,\n              workspace_id,\n              last_message_id,\n              session,\n              Self::PUBLISH_COLLAB_LIMIT,\n            )\n            .await\n            {\n              tracing::error!(\n                \"failed to send missing collabs for workspace {}: {}\",\n                workspace_id,\n                err\n              );\n            }\n          }\n          .into_actor(self),\n        );\n      }\n    }\n  }\n}\n\nimpl Handler<Leave> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, msg: Leave, ctx: &mut Self::Context) -> Self::Result {\n    if msg.workspace_id == self.workspace_id {\n      self.sessions_by_client_id.remove(&msg.session_id);\n      tracing::trace!(\n        \"detached session `{}` from workspace {}\",\n        msg.session_id,\n        msg.workspace_id\n      );\n\n      if self.sessions_by_client_id.is_empty() {\n        self.schedule_terminate(ctx);\n      }\n    }\n  }\n}\n\nimpl Handler<Terminate> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, msg: Terminate, ctx: &mut Self::Context) -> Self::Result {\n    if msg.workspace_id == self.workspace_id && self.sessions_by_client_id.is_empty() {\n      ctx.stop();\n    }\n  }\n}\n\nimpl Handler<WsInput> for Workspace {\n  type Result = AtomicResponse<Self, ()>;\n  fn handle(&mut self, msg: WsInput, _: &mut Self::Context) -> Self::Result {\n    let store = self.manager.clone();\n    if let Some(sender) = self.sessions_by_client_id.get(&msg.client_id) {\n      AtomicResponse::new(Box::pin(\n        Self::hande_ws_input(store, sender.clone(), msg).into_actor(self),\n      ))\n    } else {\n      AtomicResponse::new(Box::pin(fut::ready(())))\n    }\n  }\n}\n\nimpl Handler<PublishUpdate> for Workspace {\n  type Result = AtomicResponse<Self, ()>;\n  fn handle(&mut self, msg: PublishUpdate, ctx: &mut Self::Context) -> Self::Result {\n    let store = self.manager.clone();\n    let workspace_id = self.workspace_id;\n    if self.sessions_by_client_id.is_empty() {\n      // this is a single call message (i.e. called from a non-session context like HTTP request)\n      // so if there are no active sessions, we should schedule a termination\n      self.schedule_terminate(ctx);\n    }\n    AtomicResponse::new(Box::pin(\n      Self::publish_update(store, workspace_id, msg).into_actor(self),\n    ))\n  }\n}\n\nimpl Handler<WorkspaceFolder> for Workspace {\n  type Result = ResponseActFuture<Self, ()>;\n  fn handle(&mut self, msg: WorkspaceFolder, ctx: &mut Self::Context) -> Self::Result {\n    let store = self.manager.clone();\n    if self.sessions_by_client_id.is_empty() {\n      // this is a single call message (i.e. called from a non-session context like HTTP request)\n      // so if there are no active sessions, we should schedule a termination\n      self.schedule_terminate(ctx);\n    }\n    Box::pin(Self::get_folder(store, msg).into_actor(self))\n  }\n}\n\nimpl Handler<Snapshot> for Workspace {\n  type Result = ResponseActFuture<Self, ()>;\n\n  fn handle(&mut self, _: Snapshot, _: &mut Self::Context) -> Self::Result {\n    use actix::ActorFutureExt;\n    let store = self.manager.clone();\n    let workspace_id = self.workspace_id;\n    let up_to = self.last_message_id;\n    Box::pin(\n      async move { store.snapshot_workspace(workspace_id, up_to).await }\n        .into_actor(self)\n        .map(move |res, act, ctx| match res {\n          Ok(_) => {\n            tracing::trace!(\"workspace {} snapshot complete\", workspace_id);\n            act.snapshot_handle = Some(ctx.notify_later(Snapshot, Self::SNAPSHOT_INTERVAL));\n          },\n          Err(err) => {\n            tracing::error!(\"failed to snapshot workspace {}: {}\", workspace_id, err)\n          },\n        }),\n    )\n  }\n}\n\nimpl Handler<CleanupPermissionCaches> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, _: CleanupPermissionCaches, ctx: &mut Self::Context) -> Self::Result {\n    // Schedule the next cleanup\n    self.permission_cache_cleanup_handle = Some(ctx.notify_later(\n      CleanupPermissionCaches,\n      Self::PERMISSION_CACHE_CLEANUP_INTERVAL,\n    ));\n\n    // Collect sessions to avoid borrowing issues\n    let sessions: Vec<WorkspaceSessionHandle> =\n      self.sessions_by_client_id.values().cloned().collect();\n\n    // Execute cleanup asynchronously\n    ctx.spawn(\n      async move {\n        for session in sessions {\n          session.cleanup_expired_cache().await;\n        }\n      }\n      .into_actor(self)\n      .map(|_, _, _| ()),\n    );\n  }\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\npub struct Terminate {\n  pub workspace_id: WorkspaceId,\n}\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\nstruct Snapshot;\n\n#[derive(actix::Message)]\n#[rtype(result = \"()\")]\nstruct CleanupPermissionCaches;\n\nimpl Handler<UpdateUserPermissions> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, msg: UpdateUserPermissions, _: &mut Self::Context) -> Self::Result {\n    // TODO(nathan): send message when documents permission changed\n    // Find sessions for the specific user\n    let user_sessions: Vec<WorkspaceSessionHandle> = self\n      .sessions_by_client_id\n      .values()\n      .filter(|session| session.uid == msg.uid)\n      .cloned()\n      .collect();\n\n    // Apply permission updates to user's sessions\n    if !user_sessions.is_empty() {\n      tokio::spawn(async move {\n        for session in user_sessions {\n          session.apply_permission_updates(msg.updates.clone()).await;\n        }\n      });\n    }\n  }\n}\n\nimpl Handler<BroadcastPermissionChanges> for Workspace {\n  type Result = ();\n\n  fn handle(&mut self, msg: BroadcastPermissionChanges, _: &mut Self::Context) -> Self::Result {\n    // TODO(nathan): send message when list of documents permission changed\n    let sessions: Vec<WorkspaceSessionHandle> = self\n      .sessions_by_client_id\n      .values()\n      .filter(|session| {\n        // Exclude the user who made the change (if specified)\n        if let Some(exclude_uid) = msg.exclude_uid {\n          session.uid != exclude_uid\n        } else {\n          true\n        }\n      })\n      .cloned()\n      .collect();\n\n    let changes = msg.changes;\n    tokio::spawn(async move {\n      for session in sessions {\n        if changes.workspace_level_change {\n          // Workspace-level change: clear all permissions for this user\n          session.clear_permission_cache().await;\n        } else {\n          session\n            .apply_permission_updates(changes.updates.clone())\n            .await;\n        }\n      }\n    });\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct PermissionUpdate {\n  pub object_id: ObjectId,\n  pub permission_type: PermissionType,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]\npub enum PermissionType {\n  NoAccess,\n  Read,\n  Write,\n}\n\nimpl PermissionType {\n  pub fn can_read(&self) -> bool {\n    matches!(self, PermissionType::Read | PermissionType::Write)\n  }\n  pub fn can_write(&self) -> bool {\n    matches!(self, PermissionType::Write)\n  }\n\n  /// Check if this permission is higher than another permission\n  pub fn is_higher_than(&self, other: &PermissionType) -> bool {\n    self > other\n  }\n\n  /// Check if this permission is at least as high as another permission\n  pub fn is_at_least(&self, other: &PermissionType) -> bool {\n    self >= other\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct BulkPermissionUpdate {\n  pub updates: Vec<PermissionUpdate>,\n  pub workspace_level_change: bool, // true if workspace role changed\n}\n\n#[derive(Clone)]\nstruct WorkspaceSessionHandle {\n  uid: i64,\n  workspace_id: WorkspaceId,\n  collab_origin: CollabOrigin,\n  conn: Addr<WsSession>,\n  // Permission cache with expiration\n  permission_cache: Arc<RwLock<HashMap<ObjectId, (PermissionType, Instant)>>>,\n  cache_ttl: Duration,\n}\n\nimpl WorkspaceSessionHandle {\n  fn new(\n    uid: i64,\n    workspace_id: WorkspaceId,\n    collab_origin: CollabOrigin,\n    conn: Addr<WsSession>,\n  ) -> Self {\n    Self::new_with_cache_ttl(\n      uid,\n      workspace_id,\n      collab_origin,\n      conn,\n      Duration::from_secs(300), // 5 minutes cache TTL\n    )\n  }\n\n  fn new_with_cache_ttl(\n    uid: i64,\n    workspace_id: WorkspaceId,\n    collab_origin: CollabOrigin,\n    conn: Addr<WsSession>,\n    cache_ttl: Duration,\n  ) -> Self {\n    Self {\n      uid,\n      workspace_id,\n      collab_origin,\n      conn,\n      permission_cache: Arc::new(RwLock::new(HashMap::new())),\n      cache_ttl,\n    }\n  }\n\n  async fn can_write_collab(\n    &self,\n    store: &Arc<CollabManager>,\n    object_id: &ObjectId,\n  ) -> Result<bool, AppError> {\n    let now = Instant::now();\n    if let Some((permission, cached_at)) = self.permission_cache.read().await.get(object_id) {\n      if now.duration_since(*cached_at) < self.cache_ttl {\n        return Ok(permission.can_write());\n      }\n    }\n\n    // Cache miss or expired, check permission and update cache\n    let has_permission = store\n      .enforce_write_collab(&self.workspace_id, &self.uid, object_id)\n      .await\n      .is_ok();\n\n    // Update cache with the actual permission state\n    let permission_type = if has_permission {\n      PermissionType::Write\n    } else {\n      PermissionType::NoAccess\n    };\n\n    self\n      .permission_cache\n      .write()\n      .await\n      .insert(*object_id, (permission_type, now));\n\n    Ok(has_permission)\n  }\n\n  async fn can_read_collab(\n    &self,\n    store: &Arc<CollabManager>,\n    object_id: &ObjectId,\n  ) -> Result<bool, AppError> {\n    let now = Instant::now();\n    if let Some((permission, cached_at)) = self.permission_cache.read().await.get(object_id) {\n      if now.duration_since(*cached_at) < self.cache_ttl {\n        return Ok(permission.can_read());\n      }\n    }\n\n    let has_permission = store\n      .enforce_read_collab(&self.workspace_id, &self.uid, object_id)\n      .await\n      .is_ok();\n\n    let mut cache = self.permission_cache.write().await;\n    if has_permission {\n      // Check if there's already a higher permission cached\n      if let Some((existing_permission, _)) = cache.get(object_id) {\n        if existing_permission.is_higher_than(&PermissionType::Read) {\n          return Ok(true);\n        }\n      }\n      cache.insert(*object_id, (PermissionType::Read, now));\n    } else {\n      cache.insert(*object_id, (PermissionType::NoAccess, now));\n    }\n\n    Ok(has_permission)\n  }\n\n  /// Apply permission updates and send WebSocket notification\n  async fn apply_permission_updates(&self, updates: Vec<PermissionUpdate>) {\n    let mut cache = self.permission_cache.write().await;\n    let now = Instant::now();\n    for update in updates {\n      // Always allow updates from NoAccess, but prevent downgrades from higher permissions\n      if let Some((existing_permission, _)) = cache.get(&update.object_id) {\n        // Allow update if:\n        // 1. Current permission is NoAccess (always allow upgrade from no access)\n        // 2. New permission is at least as high as existing permission\n        if *existing_permission == PermissionType::NoAccess\n          || update.permission_type.is_at_least(existing_permission)\n        {\n          cache.insert(update.object_id, (update.permission_type, now));\n        }\n      } else {\n        // No existing permission, insert the new one\n        cache.insert(update.object_id, (update.permission_type, now));\n      }\n    }\n  }\n\n  /// Clear all cached permissions (useful when user's workspace role changes)\n  async fn clear_permission_cache(&self) {\n    let mut cache = self.permission_cache.write().await;\n    cache.clear();\n  }\n\n  /// Remove expired entries from cache\n  async fn cleanup_expired_cache(&self) {\n    let now = Instant::now();\n    let mut cache = self.permission_cache.write().await;\n    cache.retain(|_, (_, cached_at)| now.duration_since(*cached_at) < self.cache_ttl);\n  }\n}\n\n#[inline]\nfn is_empty_update(update: &[u8], flag: &UpdateFlags) -> bool {\n  (flag == &UpdateFlags::Lib0v1 && update == Update::EMPTY_V1)\n    || (flag == &UpdateFlags::Lib0v2 && update == Update::EMPTY_V2)\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/src/ws2/mod.rs",
    "content": "mod actors;\n\npub use crate::collab::collab_manager::*;\npub use crate::collab::snapshot_scheduler::*;\npub use actors::*;\n"
  },
  {
    "path": "services/appflowy-collaborate/tests/indexer_test.rs",
    "content": "use collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse collab_document::document::Document;\nuse workspace_template::document::getting_started::{\n  get_initial_document_data, getting_started_document_data,\n};\n\n#[test]\nfn document_plain_text() {\n  let doc = getting_started_document_data().unwrap();\n  let options = CollabOptions::new(\"1\".to_string(), default_client_id());\n  let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n  let document = Document::create_with_data(collab, doc).unwrap();\n  let text = document.to_plain_text().join(\"\");\n  let expected = \"Welcome to AppFlowy$Download for macOS, Windows, and Linux link$$$quick start Ask AI powered by advanced AI models: chat, search, write, and much more ✨---❤\\u{fe0f}Love AppFlowy and open source? Follow our latest product updates:Twitter: @appflowyReddit: r/appflowyGithub\";\n  assert_eq!(&text, expected);\n}\n\n#[test]\nfn document_plain_text_with_nested_blocks() {\n  let doc = get_initial_document_data().unwrap();\n  let options = CollabOptions::new(\"1\".to_string(), default_client_id());\n  let collab = Collab::new_with_options(CollabOrigin::Server, options).unwrap();\n  let document = Document::create_with_data(collab, doc).unwrap();\n  let text = document.to_plain_text().join(\"\");\n  let expected = \"Welcome to AppFlowy!Here are the basicsHere is H3Click anywhere and just start typing.  Click Enter to create a new line.Highlight any text, and use the editing menu to style your writing however you like.As soon as you type / a menu will pop up. Select different types of content blocks you can add.Type / followed by /bullet or /num to create a list.Click + New Page button at the bottom of your sidebar to add a new page.Click + next to any page title in the sidebar to quickly add a new subpage, Document, Grid, or Kanban Board.---Keyboard shortcuts, markdown, and code block1. Keyboard shortcuts guide1. Markdown reference1. Type /code to insert a code block// This is the main function.fn main() {    // Print text to the console.    println!(\\\"Hello World!\\\");}This is a paragraphThis is a paragraphHave a question❓Click ? at the bottom right for help and support.This is a paragraphThis is a paragraphClick ? at the bottom right for help and support.🥰 Like AppFlowy? Follow us:GitHubTwitter: @appflowyNewsletter\";\n  assert_eq!(&text, expected);\n}\n"
  },
  {
    "path": "services/appflowy-collaborate/tests/main.rs",
    "content": "mod indexer_test;\n"
  },
  {
    "path": "services/appflowy-worker/Cargo.toml",
    "content": "[package]\nname = \"appflowy-worker\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n[[bin]]\npath = \"src/main.rs\"\nname = \"appflowy_worker\"\n\n[lib]\npath = \"src/lib.rs\"\n\n[dependencies]\ncollab.workspace = true\ncollab-entity.workspace = true\ncollab-importer.workspace = true\ncollab-folder.workspace = true\ncollab-database.workspace = true\ntracing.workspace = true\nserde.workspace = true\nserde_json.workspace = true\nanyhow.workspace = true\ndatabase.workspace = true\ndatabase-entity.workspace = true\ntokio = { workspace = true, features = [\"rt-multi-thread\", \"macros\", \"net\"] }\nredis = { workspace = true, features = [\n  \"aio\",\n  \"tokio-comp\",\n  \"connection-manager\",\n  \"streams\",\n] }\ndotenvy = \"0.15.0\"\naxum = \"0.7.4\"\nthiserror = \"1.0.58\"\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"json\"] }\nfutures = \"0.3.30\"\ninfra = { workspace = true, features = [\"request_util\"] }\nsqlx = { workspace = true, default-features = false, features = [\n  \"runtime-tokio-rustls\",\n  \"macros\",\n  \"postgres\",\n  \"uuid\",\n  \"chrono\",\n  \"migrate\",\n] }\nsecrecy = { workspace = true, features = [\"serde\"] }\naws-sdk-s3 = { version = \"1.88.0\", features = [\n  \"behavior-version-latest\",\n  \"rt-tokio\",\n] }\ntokio-util = { version = \"0.7.12\", features = [\"compat\"] }\nasync_zip = { version = \"0.0.17\", features = [\"full\"] }\nmime_guess = \"2.0\"\nbytes.workspace = true\nuuid.workspace = true\nmailer.workspace = true\nmd5.workspace = true\nbase64.workspace = true\nprometheus-client = \"0.22.3\"\nzstd.workspace = true\nindexer.workspace = true\nappflowy-collaborate = { path = \"../appflowy-collaborate\" }\nrayon = \"1.10.0\"\napp-error = { workspace = true, features = [\"sqlx_error\"] }\n"
  },
  {
    "path": "services/appflowy-worker/Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nFROM lukemathwalker/cargo-chef:latest-rust-1.86 as chef\n\n# Set the initial working directory\nWORKDIR /app\nRUN apt update && apt install lld clang -y\n\nFROM chef as planner\nCOPY . .\n\n# Compute a lock-like file for our project\nRUN cargo chef prepare --recipe-path recipe.json\n\nFROM chef as builder\n\n# Update package lists and install protobuf-compiler along with other build dependencies\nRUN apt update && apt install -y protobuf-compiler lld clang\n\nARG PROFILE=\"release\"\n\nCOPY --from=planner /app/recipe.json recipe.json\n# Build our project dependencies with optimized settings\nENV CARGO_BUILD_JOBS=4\nENV CARGO_NET_GIT_FETCH_WITH_CLI=true\nENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\nRUN echo \"Building worker with profile: ${PROFILE} \"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      cargo chef cook --release --recipe-path recipe.json; \\\n    else \\\n      cargo chef cook --recipe-path recipe.json; \\\n    fi\n\nCOPY . .\n\nWORKDIR /app/services/appflowy-worker\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      cargo build --release --bin appflowy_worker; \\\n    else \\\n      cargo build --bin appflowy_worker; \\\n    fi\n\nFROM debian:bookworm-slim AS runtime\nWORKDIR /app/services/appflowy-worker\nRUN apt-get update -y \\\n    && apt-get install -y --no-install-recommends openssl \\\n    && apt-get install -y -y ca-certificates \\\n    # Clean up\n    && apt-get autoremove -y \\\n    && apt-get clean -y \\\n    && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app/\n# Copy the binary from the appropriate target directory\nARG PROFILE=\"release\"\nRUN echo \"Building worker with profile: ${PROFILE} \"\nRUN if [ \"$PROFILE\" = \"release\" ]; then \\\n      echo \"Using release worker binary\"; \\\n    else \\\n      echo \"Using debug worker binary\"; \\\n    fi\nCOPY --from=builder /app/target/$PROFILE/appflowy_worker /usr/local/bin/appflowy_worker\nENV APP_ENVIRONMENT production\nENV RUST_BACKTRACE  1\nCMD [\"appflowy_worker\"]\n"
  },
  {
    "path": "services/appflowy-worker/README.md",
    "content": "Build docker image for appflowy worker manually\n\n```shell\ndocker buildx build -f ./services/appflowy-worker/Dockerfile  --platform linux/amd64 -t appflowyinc/appflowy_worker --push .\n```"
  },
  {
    "path": "services/appflowy-worker/deploy.env",
    "content": "APPFLOWY_WORKER_REDIS_URL=\nAPPFLOWY_WORKER_DATABASE_URL=\nAPPFLOWY_WORKER_DATABASE_NAME=postgres\nAPPFLOWY_WORKER_ENVIRONMENT=production\nAPPFLOWY_WORKER_DATABASE_MAX_CONNECTIONS=10"
  },
  {
    "path": "services/appflowy-worker/src/application.rs",
    "content": "use crate::config::{Config, DatabaseSetting, Environment, S3Setting};\nuse anyhow::Error;\nuse redis::aio::ConnectionManager;\nuse sqlx::postgres::PgPoolOptions;\nuse sqlx::PgPool;\n\nuse crate::import_worker::worker::run_import_worker;\nuse aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};\n\nuse crate::import_worker::email_notifier::EmailNotifier;\nuse crate::s3_client::S3ClientImpl;\n\nuse axum::Router;\n\nuse crate::mailer::AFWorkerMailer;\nuse crate::metric::ImportMetrics;\nuse appflowy_worker::indexer_worker::{run_background_indexer, BackgroundIndexerConfig};\nuse axum::extract::State;\nuse axum::http::StatusCode;\nuse axum::response::IntoResponse;\nuse axum::routing::get;\nuse indexer::metrics::EmbeddingMetrics;\nuse indexer::vector::embedder::get_open_ai_config;\nuse infra::env_util::get_env_var;\nuse mailer::sender::Mailer;\nuse secrecy::ExposeSecret;\nuse std::sync::{Arc, Once};\nuse std::time::Duration;\nuse tokio::net::TcpListener;\nuse tokio::task::LocalSet;\nuse tracing::info;\nuse tracing::subscriber::set_global_default;\nuse tracing_subscriber::layer::SubscriberExt;\nuse tracing_subscriber::EnvFilter;\n\npub async fn run_server(\n  listener: TcpListener,\n  config: Config,\n) -> Result<(), Box<dyn std::error::Error>> {\n  dotenvy::dotenv().ok();\n  init_subscriber(&config.app_env);\n  info!(\"config loaded: {:?}\", &config);\n\n  // Start the server\n  info!(\"Starting server at: {:?}\", listener.local_addr());\n  create_app(listener, config).await.unwrap();\n  Ok(())\n}\n\npub fn init_subscriber(app_env: &Environment) {\n  static START: Once = Once::new();\n  START.call_once(|| {\n    let env_filter = EnvFilter::from_default_env();\n\n    let builder = tracing_subscriber::fmt()\n      .with_target(true)\n      .with_thread_ids(false)\n      .with_file(false);\n\n    match app_env {\n      Environment::Local => {\n        let subscriber = builder\n          .with_ansi(true)\n          .with_target(false)\n          .with_file(false)\n          .pretty()\n          .finish()\n          .with(env_filter);\n        set_global_default(subscriber).unwrap();\n      },\n      Environment::Production => {\n        let subscriber = builder.json().finish().with(env_filter);\n        set_global_default(subscriber).unwrap();\n      },\n    }\n  });\n}\n\npub async fn create_app(listener: TcpListener, config: Config) -> Result<(), Error> {\n  // Postgres\n  info!(\"Preparing to run database migrations...\");\n  let pg_pool = get_connection_pool(&config.db_settings).await?;\n\n  // Redis\n  let redis_client = redis::Client::open(config.redis_url.clone())\n    .expect(\"failed to create redis client\")\n    .get_connection_manager()\n    .await\n    .expect(\"failed to get redis connection manager\");\n\n  let mailer = get_worker_mailer(&config).await?;\n  let s3_client = get_aws_s3_client(&config.s3_setting).await?;\n  let metrics = AppMetrics::new();\n\n  let state = AppState {\n    redis_client,\n    pg_pool,\n    s3_client,\n    mailer: mailer.clone(),\n    metrics,\n  };\n\n  let local_set = LocalSet::new();\n  let email_notifier = EmailNotifier::new(mailer);\n  let tick_interval = get_env_var(\"APPFLOWY_WORKER_IMPORT_TICK_INTERVAL\", \"10\")\n    .parse::<u64>()\n    .unwrap_or(10);\n\n  // Maximum file size for import\n  let maximum_import_file_size =\n    get_env_var(\"APPFLOWY_WORKER_MAX_IMPORT_FILE_SIZE\", \"1_000_000_000\")\n      .parse::<u64>()\n      .unwrap_or(1_000_000_000);\n\n  let import_worker_fut = local_set.run_until(run_import_worker(\n    state.pg_pool.clone(),\n    state.redis_client.clone(),\n    Some(state.metrics.import_metrics.clone()),\n    Arc::new(state.s3_client.clone()),\n    Arc::new(email_notifier),\n    \"import_task_stream\",\n    tick_interval,\n    maximum_import_file_size,\n  ));\n\n  let (open_ai_config, azure_ai_config) = get_open_ai_config();\n  let indexer_config = BackgroundIndexerConfig {\n    enable: appflowy_collaborate::config::get_env_var(\"APPFLOWY_INDEXER_ENABLED\", \"true\")\n      .parse::<bool>()\n      .unwrap_or(true),\n    open_ai_config,\n    azure_ai_config,\n    tick_interval_secs: 10,\n  };\n\n  tokio::spawn(run_background_indexer(\n    state.pg_pool.clone(),\n    state.redis_client.clone(),\n    state.metrics.embedder_metrics.clone(),\n    indexer_config,\n  ));\n\n  let app = Router::new()\n    .route(\"/metrics\", get(metrics_handler))\n    .with_state(Arc::new(state));\n\n  tokio::select! {\n    _ = import_worker_fut => {\n      info!(\"Notion importer stopped\");\n    },\n    _ = axum::serve(listener, app) => {\n      info!(\"worker stopped\");\n    },\n  }\n\n  Ok(())\n}\n\n#[derive(Clone)]\npub struct AppState {\n  pub redis_client: ConnectionManager,\n  pub pg_pool: PgPool,\n  pub s3_client: S3ClientImpl,\n  #[allow(dead_code)]\n  pub mailer: AFWorkerMailer,\n  pub metrics: AppMetrics,\n}\n\nasync fn get_worker_mailer(config: &Config) -> Result<AFWorkerMailer, Error> {\n  let mailer = Mailer::new(\n    config.mailer.smtp_username.clone(),\n    config.mailer.smtp_email.clone(),\n    config.mailer.smtp_password.clone(),\n    &config.mailer.smtp_host,\n    config.mailer.smtp_port,\n    config.mailer.smtp_tls_kind.as_str(),\n  )\n  .await?;\n\n  AFWorkerMailer::new(mailer).await\n}\n\nasync fn get_connection_pool(setting: &DatabaseSetting) -> Result<PgPool, Error> {\n  info!(\n    \"Connecting to postgres database with setting: {:?}\",\n    setting\n  );\n  PgPoolOptions::new()\n    .max_connections(setting.max_connections)\n    .acquire_timeout(Duration::from_secs(10))\n    .max_lifetime(Duration::from_secs(30 * 60))\n    .idle_timeout(Duration::from_secs(30))\n    .connect_with(setting.with_db())\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to connect to postgres database: {}\", e))\n}\n\npub async fn get_aws_s3_client(s3_setting: &S3Setting) -> Result<S3ClientImpl, Error> {\n  let credentials = Credentials::new(\n    s3_setting.access_key.clone(),\n    s3_setting.secret_key.expose_secret().clone(),\n    None,\n    None,\n    \"appflowy-worker\",\n  );\n  let shared_credentials = SharedCredentialsProvider::new(credentials);\n\n  // Configure the AWS SDK\n  let config_builder = aws_sdk_s3::Config::builder()\n    .credentials_provider(shared_credentials)\n    .force_path_style(true)\n    .region(Region::new(s3_setting.region.clone()));\n\n  let config = if s3_setting.use_minio {\n    config_builder.endpoint_url(&s3_setting.minio_url).build()\n  } else {\n    config_builder.build()\n  };\n  let client = aws_sdk_s3::Client::from_conf(config);\n  Ok(S3ClientImpl {\n    inner: client,\n    bucket: s3_setting.bucket.clone(),\n  })\n}\n\n#[derive(Clone)]\npub struct AppMetrics {\n  #[allow(dead_code)]\n  registry: Arc<prometheus_client::registry::Registry>,\n  import_metrics: Arc<ImportMetrics>,\n  embedder_metrics: Arc<EmbeddingMetrics>,\n}\n\nimpl AppMetrics {\n  pub fn new() -> Self {\n    let mut registry = prometheus_client::registry::Registry::default();\n    let import_metrics = Arc::new(ImportMetrics::register(&mut registry));\n    let embedder_metrics = Arc::new(EmbeddingMetrics::register(&mut registry));\n    Self {\n      registry: Arc::new(registry),\n      import_metrics,\n      embedder_metrics,\n    }\n  }\n}\n\nasync fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {\n  let mut buffer = String::new();\n  if let Err(err) = prometheus_client::encoding::text::encode(&mut buffer, &state.metrics.registry)\n  {\n    return (\n      StatusCode::INTERNAL_SERVER_ERROR,\n      format!(\"Failed to encode metrics: {:?}\", err),\n    )\n      .into_response();\n  }\n  (StatusCode::OK, buffer).into_response()\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/config.rs",
    "content": "use anyhow::{Context, Error};\nuse infra::env_util::get_env_var;\nuse mailer::config::MailerSetting;\nuse secrecy::Secret;\nuse serde::Deserialize;\nuse sqlx::postgres::{PgConnectOptions, PgSslMode};\nuse std::{fmt::Display, str::FromStr};\n\n#[derive(Debug, Clone)]\npub struct Config {\n  pub app_env: Environment,\n  pub redis_url: String,\n  pub db_settings: DatabaseSetting,\n  pub s3_setting: S3Setting,\n  pub mailer: MailerSetting,\n}\n\nimpl Config {\n  pub fn from_env() -> Result<Self, Error> {\n    Ok(Config {\n      app_env: get_env_var(\"APPFLOWY_WORKER_ENVIRONMENT\", \"local\")\n        .parse()\n        .context(\"fail to get APPFLOWY_WORKER_ENVIRONMENT\")?,\n      redis_url: get_env_var(\"APPFLOWY_WORKER_REDIS_URL\", \"redis://localhost:6379\"),\n      db_settings: DatabaseSetting {\n        pg_conn_opts: PgConnectOptions::from_str(&get_env_var(\n          \"APPFLOWY_WORKER_DATABASE_URL\",\n          \"postgres://postgres:password@localhost:5432/postgres\",\n        ))?,\n        require_ssl: get_env_var(\"APPFLOWY_WORKER_DATABASE_REQUIRE_SSL\", \"false\")\n          .parse()\n          .context(\"fail to get APPFLOWY_WORKER_DATABASE_REQUIRE_SSL\")?,\n        max_connections: get_env_var(\"APPFLOWY_WORKER_DATABASE_MAX_CONNECTIONS\", \"10\")\n          .parse()\n          .context(\"fail to get APPFLOWY_WORKER_DATABASE_MAX_CONNECTIONS\")?,\n        database_name: get_env_var(\"APPFLOWY_WORKER_DATABASE_NAME\", \"postgres\"),\n      },\n      s3_setting: S3Setting {\n        use_minio: get_env_var(\"APPFLOWY_S3_USE_MINIO\", \"true\")\n          .parse()\n          .context(\"fail to get APPFLOWY_S3_USE_MINIO\")?,\n        minio_url: get_env_var(\"APPFLOWY_S3_MINIO_URL\", \"http://localhost:9000\"),\n        access_key: get_env_var(\"APPFLOWY_S3_ACCESS_KEY\", \"minioadmin\"),\n        secret_key: get_env_var(\"APPFLOWY_S3_SECRET_KEY\", \"minioadmin\").into(),\n        bucket: get_env_var(\"APPFLOWY_S3_BUCKET\", \"appflowy\"),\n        region: get_env_var(\"APPFLOWY_S3_REGION\", \"\"),\n      },\n      mailer: MailerSetting {\n        smtp_host: get_env_var(\"APPFLOWY_MAILER_SMTP_HOST\", \"smtp.gmail.com\"),\n        smtp_port: get_env_var(\"APPFLOWY_MAILER_SMTP_PORT\", \"465\").parse()?,\n        smtp_email: get_env_var(\"APPFLOWY_MAILER_SMTP_EMAIL\", \"sender@example.com\"),\n        // `smtp_username` could be the same as `smtp_email`, but may not have to be.\n        // For example:\n        //  - Azure Communication services uses a string of the format <resource name>.<app id>.<tenant id>\n        //  - SendGrid uses the string apikey\n        // Adapted from: https://github.com/AppFlowy-IO/AppFlowy-Cloud/issues/984\n        smtp_username: get_env_var(\"APPFLOWY_MAILER_SMTP_USERNAME\", \"sender@example.com\"),\n        smtp_password: get_env_var(\"APPFLOWY_MAILER_SMTP_PASSWORD\", \"password\").into(),\n        smtp_tls_kind: get_env_var(\"APPFLOWY_MAILER_SMTP_TLS_KIND\", \"wrapper\"),\n      },\n    })\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct DatabaseSetting {\n  pub pg_conn_opts: PgConnectOptions,\n  pub require_ssl: bool,\n  pub max_connections: u32,\n  pub database_name: String,\n}\n\nimpl DatabaseSetting {\n  pub fn without_db(&self) -> PgConnectOptions {\n    let ssl_mode = if self.require_ssl {\n      PgSslMode::Require\n    } else {\n      PgSslMode::Prefer\n    };\n    let options = self.pg_conn_opts.clone();\n    options.ssl_mode(ssl_mode)\n  }\n\n  pub fn with_db(&self) -> PgConnectOptions {\n    self.without_db().database(&self.database_name)\n  }\n}\n\nimpl Display for DatabaseSetting {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    let masked_pg_conn_opts = self.pg_conn_opts.clone().password(\"********\");\n    write!(\n      f,\n      \"DatabaseSetting {{ pg_conn_opts: {:?}, require_ssl: {}, max_connections: {} }}\",\n      masked_pg_conn_opts, self.require_ssl, self.max_connections\n    )\n  }\n}\n\n#[allow(dead_code)]\n#[derive(Debug, Clone)]\npub struct StreamSetting {\n  /// The key of the stream that contains control event, [CollabControlEvent].\n  pub control_key: String,\n}\n\n#[derive(Clone, Debug, Deserialize)]\npub enum Environment {\n  Local,\n  Production,\n}\n\nimpl FromStr for Environment {\n  type Err = anyhow::Error;\n\n  fn from_str(s: &str) -> Result<Self, Self::Err> {\n    match s.to_lowercase().as_str() {\n      \"local\" => Ok(Self::Local),\n      \"production\" => Ok(Self::Production),\n      other => anyhow::bail!(\n        \"{} is not a supported environment. Use either `local` or `production`.\",\n        other\n      ),\n    }\n  }\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct S3Setting {\n  pub use_minio: bool,\n  pub minio_url: String,\n  pub access_key: String,\n  pub secret_key: Secret<String>,\n  pub bucket: String,\n  pub region: String,\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/error.rs",
    "content": "pub use collab_importer::error::ImporterError as CollabImporterError;\n#[derive(thiserror::Error, Debug)]\npub enum WorkerError {\n  #[error(transparent)]\n  ZipError(#[from] async_zip::error::ZipError),\n\n  #[error(\"Record not found: {0}\")]\n  RecordNotFound(String),\n\n  #[error(transparent)]\n  IOError(#[from] std::io::Error),\n\n  #[error(transparent)]\n  ImportError(#[from] ImportError),\n\n  #[error(\"S3 service unavailable: {0}\")]\n  S3ServiceUnavailable(String),\n\n  #[error(\"Redis stream group not exist: {0}\")]\n  StreamGroupNotExist(String),\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n}\n\n#[derive(thiserror::Error, Debug)]\npub enum ImportError {\n  #[error(transparent)]\n  ImportCollabError(#[from] CollabImporterError),\n\n  #[error(\"Can not open the workspace:{0}\")]\n  CannotOpenWorkspace(String),\n\n  #[error(\"Failed to unzip file: {0}\")]\n  UnZipFileError(String),\n\n  #[error(\"Upload file not found\")]\n  UploadFileNotFound,\n\n  #[error(\"Upload file expired\")]\n  UploadFileExpire,\n\n  #[error(\"Please upgrade to the latest version of the app\")]\n  UpgradeToLatestVersion(String),\n\n  #[error(\"Upload file too large\")]\n  UploadFileTooLarge {\n    file_size_in_mb: f64,\n    max_size_in_mb: f64,\n  },\n\n  #[error(transparent)]\n  Internal(#[from] anyhow::Error),\n\n  #[error(transparent)]\n  InvalidUuid(#[from] uuid::Error),\n}\n\nimpl From<WorkerError> for ImportError {\n  fn from(err: WorkerError) -> ImportError {\n    match err {\n      WorkerError::RecordNotFound(_) => ImportError::UploadFileNotFound,\n      _ => ImportError::Internal(err.into()),\n    }\n  }\n}\n\nimpl ImportError {\n  pub fn is_file_not_found(&self) -> bool {\n    match self {\n      ImportError::ImportCollabError(err) => {\n        matches!(err, CollabImporterError::FileNotFound)\n      },\n      _ => false,\n    }\n  }\n  pub fn report(&self, task_id: &str) -> (String, String) {\n    match self {\n      ImportError::ImportCollabError(error) => match error {\n        CollabImporterError::InvalidPath(s) => (\n          format!(\n            \"Task ID: {} - The provided file path is invalid. Please check the path and try again.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Invalid path: {}\", task_id, s),\n        ),\n        CollabImporterError::InvalidPathFormat => (\n          format!(\n            \"Task ID: {} - The file path format is incorrect. Please ensure it is in the correct format.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Invalid path format\", task_id),\n        ),\n        CollabImporterError::InvalidFileType(file_type) => (\n          format!(\n            \"Task ID: {} - The file type is unsupported. Please use a supported file type.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Invalid file type: {}\", task_id, file_type),\n        ),\n        CollabImporterError::ImportMarkdownError(_) => (\n          format!(\n            \"Task ID: {} - There was an issue importing the markdown file. Please verify the file contents.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Import markdown error\", task_id),\n        ),\n        CollabImporterError::ImportCsvError(_) => (\n          format!(\n            \"Task ID: {} - There was an issue importing the CSV file. Please ensure it is correctly formatted.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Import CSV error\", task_id),\n        ),\n        CollabImporterError::ParseMarkdownError(_) => (\n          format!(\n            \"Task ID: {} - Failed to parse the markdown file. Please check for any formatting issues.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Parse markdown error\", task_id),\n        ),\n        CollabImporterError::Utf8Error(_) => (\n          format!(\n            \"Task ID: {} - There was a character encoding issue. Ensure your file is in UTF-8 format.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - UTF-8 error\", task_id),\n        ),\n        CollabImporterError::IOError(_) => (\n          format!(\n            \"Task ID: {} - An input/output error occurred. Please check your file and try again.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - IO error\", task_id),\n        ),\n        CollabImporterError::FileNotFound => (\n          format!(\n            \"Task ID: {} - The specified file could not be found. Please check the file path.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - File not found\", task_id),\n        ),\n        CollabImporterError::CannotImport => (\n          format!(\n            \"Task ID: {} - The file could not be imported. Please ensure it is in a valid format.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Cannot import file\", task_id),\n        ),\n        CollabImporterError::Internal(_) => (\n          format!(\n            \"Task ID: {} - An internal error occurred during the import process. Please try again later.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Internal error\", task_id),\n        ),\n      },\n      ImportError::CannotOpenWorkspace(err) => (\n        format!(\n          \"Task ID: {} - Unable to open the workspace. Please verify the workspace and try again.\",\n          task_id\n        ),\n        format!(\"Task ID: {} - Cannot open workspace: {}\", task_id, err),\n      ),\n      ImportError::Internal(err) => (\n        format!(\n          \"Task ID: {} - An internal error occurred. Please try again or contact support.\",\n          task_id\n        ),\n        format!(\"Task ID: {} - Internal error: {}\", task_id, err),\n      ),\n      ImportError::UnZipFileError(_) => {\n        (\n          format!(\n            \"Task ID: {} - There was an issue unzipping the file. Please check the file and try again.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Unzip file error\", task_id),\n        )\n      }\n      ImportError::UploadFileNotFound => {\n        (\n          format!(\n            \"Task ID: {} - The upload file could not be found. Please check the file and try again.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Upload file not found\", task_id),\n        )\n      }\n      ImportError::UploadFileExpire => {\n        (\n          format!(\n            \"Task ID: {} - The upload file has expired. Please upload the file again.\",\n            task_id\n          ),\n          format!(\"Task ID: {} - Upload file expired\", task_id),\n        )\n      }\n      ImportError::UpgradeToLatestVersion(s) => {\n        (\n          format!(\n            \"Task ID: {} - {}, please upgrade to the latest version of the app to import this file\",\n            task_id,\n            s,\n\n          ),\n          format!(\"Task ID: {} - Upgrade to latest version\", task_id),\n        )\n      }\n      ImportError::UploadFileTooLarge{ file_size_in_mb, max_size_in_mb}=> {\n        (\n          format!(\n            \"Task ID: {} - The file size is too large. The maximum file size allowed is {} MB. Please upload a smaller file.\",\n            task_id,\n            max_size_in_mb,\n          ),\n          format!(\"Task ID: {} - Upload file too large: {} MB\", task_id, file_size_in_mb),\n        )\n      }\n      ImportError::InvalidUuid(err) =>  {\n        (\n          format!(\n            \"Task ID: {} - Identifier is not valid UUID: {}\",\n            task_id,\n            err\n          ),\n          format!(\"Task ID: {} - Identifier is not valid UUID\", task_id),\n        )\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/import_worker/email_notifier.rs",
    "content": "use crate::import_worker::report::{ImportNotifier, ImportProgress};\nuse crate::mailer::{AFWorkerMailer, IMPORT_FAIL_TEMPLATE, IMPORT_SUCCESS_TEMPLATE};\nuse axum::async_trait;\nuse tracing::{error, trace};\n\npub struct EmailNotifier(AFWorkerMailer);\nimpl EmailNotifier {\n  pub fn new(mailer: AFWorkerMailer) -> Self {\n    Self(mailer)\n  }\n}\n\n#[async_trait]\nimpl ImportNotifier for EmailNotifier {\n  async fn notify_progress(&self, progress: ImportProgress) {\n    match progress {\n      ImportProgress::Started { workspace_id: _ } => {},\n      ImportProgress::Finished(result) => {\n        let subject = \"Notification: Import Report\";\n        trace!(\n          \"[Import]: sending import notion report email to {}, params: {:?}\",\n          result.user_email,\n          result,\n        );\n\n        let template_name = if result.is_success {\n          IMPORT_SUCCESS_TEMPLATE\n        } else {\n          IMPORT_FAIL_TEMPLATE\n        };\n\n        if let Err(err) = self\n          .0\n          .send_email_template(\n            Some(result.user_name),\n            &result.user_email,\n            template_name,\n            result.value,\n            subject,\n          )\n          .await\n        {\n          error!(\"Failed to send import notion report email: {}\", err);\n        }\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/import_worker/mod.rs",
    "content": "pub mod email_notifier;\npub mod report;\npub mod worker;\n"
  },
  {
    "path": "services/appflowy-worker/src/import_worker/report.rs",
    "content": "use axum::async_trait;\n\n#[async_trait]\npub trait ImportNotifier: Send + Sync + 'static {\n  async fn notify_progress(&self, progress: ImportProgress);\n}\n\n#[derive(Debug, Clone)]\npub enum ImportProgress {\n  Started { workspace_id: String },\n  Finished(ImportResult),\n}\n\n#[derive(Debug, Clone)]\npub struct ImportResult {\n  pub user_name: String,\n  pub user_email: String,\n  pub is_success: bool,\n  pub value: serde_json::Value,\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/import_worker/worker.rs",
    "content": "use crate::import_worker::report::{ImportNotifier, ImportProgress, ImportResult};\nuse crate::s3_client::{download_file, AutoRemoveDownloadedFile, S3StreamResponse};\nuse anyhow::anyhow;\nuse aws_sdk_s3::primitives::ByteStream;\n\nuse crate::error::{ImportError, WorkerError};\nuse crate::mailer::ImportNotionMailerParam;\nuse crate::s3_client::S3Client;\n\nuse bytes::Bytes;\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::{EncodedCollab, EncoderVersion};\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_entity::CollabType;\nuse collab_folder::{Folder, View, ViewLayout};\nuse collab_importer::imported_collab::ImportType;\nuse collab_importer::notion::page::CollabResource;\nuse collab_importer::notion::NotionImporter;\nuse collab_importer::util::FileId;\nuse database::collab::{insert_into_af_collab_bulk_for_user, select_blob_from_af_collab};\nuse database::resource_usage::{insert_blob_metadata_bulk, BulkInsertMeta};\nuse database::workspace::{\n  delete_from_workspace, select_import_task, select_workspace_database_storage_id,\n  update_import_task_status, update_updated_at_of_workspace_with_uid, update_workspace_status,\n  ImportTaskState,\n};\nuse database_entity::dto::CollabParams;\n\nuse crate::metric::ImportMetrics;\nuse async_zip::base::read::stream::{Ready, ZipFileReader};\nuse collab_importer::zip_tool::async_zip::async_unzip;\nuse collab_importer::zip_tool::sync_zip::sync_unzip;\n\nuse futures::stream::FuturesUnordered;\nuse futures::{stream, AsyncBufRead, AsyncReadExt, StreamExt};\nuse infra::env_util::get_env_var;\nuse redis::aio::ConnectionManager;\nuse redis::streams::{\n  StreamClaimOptions, StreamClaimReply, StreamId, StreamPendingReply, StreamReadOptions,\n  StreamReadReply,\n};\nuse redis::{AsyncCommands, RedisResult, Value};\n\nuse collab::core::collab::default_client_id;\nuse database::pg_row::AFImportTask;\nuse serde::{Deserialize, Serialize};\nuse serde_json::from_str;\nuse sqlx::types::chrono::{DateTime, TimeZone, Utc};\nuse sqlx::PgPool;\nuse std::collections::{HashMap, HashSet};\nuse std::env::temp_dir;\nuse std::fmt::Display;\nuse std::fs::Permissions;\nuse std::io::ErrorKind;\nuse std::ops::DerefMut;\nuse std::os::unix::fs::PermissionsExt;\nuse std::path::{Path, PathBuf};\nuse std::pin::Pin;\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse std::time::Duration;\nuse tokio::fs;\nuse tokio::task::spawn_local;\nuse tokio::time::{interval, MissedTickBehavior};\nuse tokio_util::compat::TokioAsyncReadCompatExt;\nuse tracing::{error, info, trace, warn};\nuse uuid::Uuid;\n\nconst GROUP_NAME: &str = \"import_task_group\";\nconst CONSUMER_NAME: &str = \"appflowy_worker\";\nconst MAXIMUM_CONTENT_LENGTH: &str = \"3221225472\";\n\n#[allow(clippy::too_many_arguments)]\npub async fn run_import_worker(\n  pg_pool: PgPool,\n  mut redis_client: ConnectionManager,\n  metrics: Option<Arc<ImportMetrics>>,\n  s3_client: Arc<dyn S3Client>,\n  notifier: Arc<dyn ImportNotifier>,\n  stream_name: &str,\n  tick_interval_secs: u64,\n  max_import_file_size: u64,\n) -> Result<(), ImportError> {\n  info!(\"Starting importer worker\");\n  if let Err(err) = ensure_consumer_group(stream_name, GROUP_NAME, &mut redis_client).await {\n    error!(\"Failed to ensure consumer group: {:?}\", err);\n  }\n\n  let storage_dir = temp_dir();\n  process_un_acked_tasks(\n    &storage_dir,\n    &mut redis_client,\n    &s3_client,\n    &pg_pool,\n    stream_name,\n    GROUP_NAME,\n    CONSUMER_NAME,\n    notifier.clone(),\n    &metrics,\n    max_import_file_size,\n  )\n  .await;\n\n  process_upcoming_tasks(\n    &storage_dir,\n    &mut redis_client,\n    &s3_client,\n    pg_pool,\n    stream_name,\n    GROUP_NAME,\n    CONSUMER_NAME,\n    notifier.clone(),\n    tick_interval_secs,\n    &metrics,\n    max_import_file_size,\n  )\n  .await?;\n\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn process_un_acked_tasks(\n  storage_dir: &Path,\n  redis_client: &mut ConnectionManager,\n  s3_client: &Arc<dyn S3Client>,\n  pg_pool: &PgPool,\n  stream_name: &str,\n  group_name: &str,\n  consumer_name: &str,\n  notifier: Arc<dyn ImportNotifier>,\n  metrics: &Option<Arc<ImportMetrics>>,\n  maximum_import_file_size: u64,\n) {\n  // when server restarts, we need to check if there are any unacknowledged tasks\n  match get_un_ack_tasks(stream_name, group_name, consumer_name, redis_client).await {\n    Ok(un_ack_tasks) => {\n      info!(\"Found {} unacknowledged tasks\", un_ack_tasks.len());\n      for un_ack_task in un_ack_tasks {\n        let context = TaskContext {\n          storage_dir: storage_dir.to_path_buf(),\n          redis_client: redis_client.clone(),\n          s3_client: s3_client.clone(),\n          pg_pool: pg_pool.clone(),\n          notifier: notifier.clone(),\n          metrics: metrics.clone(),\n          maximum_import_file_size,\n        };\n        // Ignore the error here since the consume task will handle the error\n        let _ = consume_task(\n          context,\n          un_ack_task.task,\n          stream_name,\n          group_name,\n          un_ack_task.stream_id.id,\n        )\n        .await;\n      }\n    },\n    Err(err) => error!(\"Failed to get unacknowledged tasks: {:?}\", err),\n  }\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn process_upcoming_tasks(\n  storage_dir: &Path,\n  redis_client: &mut ConnectionManager,\n  s3_client: &Arc<dyn S3Client>,\n  pg_pool: PgPool,\n  stream_name: &str,\n  group_name: &str,\n  consumer_name: &str,\n  notifier: Arc<dyn ImportNotifier>,\n  interval_secs: u64,\n  metrics: &Option<Arc<ImportMetrics>>,\n  maximum_import_file_size: u64,\n) -> Result<(), ImportError> {\n  let options = StreamReadOptions::default()\n    .group(group_name, consumer_name)\n    .count(10);\n  let mut interval = interval(Duration::from_secs(interval_secs));\n  interval.set_missed_tick_behavior(MissedTickBehavior::Skip);\n  interval.tick().await;\n\n  loop {\n    interval.tick().await;\n\n    let tasks: StreamReadReply = match redis_client\n      .xread_options(&[stream_name], &[\">\"], &options)\n      .await\n    {\n      Ok(tasks) => tasks,\n      Err(err) => {\n        error!(\"Failed to read tasks from Redis stream: {:?}\", err);\n\n        // Use command:\n        // docker exec -it appflowy-cloud-redis-1 redis-cli FLUSHDB to generate the error\n        // NOGROUP: No such key 'import_task_stream' or consumer group 'import_task_group' in XREADGROUP with GROUP option\n        if let Some(code) = err.code() {\n          if code == \"NOGROUP\" {\n            if let Err(err) = ensure_consumer_group(stream_name, GROUP_NAME, redis_client).await {\n              error!(\"Failed to ensure consumer group: {:?}\", err);\n            }\n          }\n        }\n        continue;\n      },\n    };\n\n    let mut task_handlers = FuturesUnordered::new();\n    for stream_key in tasks.keys {\n      // For each stream key, iterate through the stream entries\n      for stream_id in stream_key.ids {\n        match ImportTask::try_from(&stream_id) {\n          Ok(import_task) => {\n            let stream_name = stream_name.to_string();\n            let group_name = group_name.to_string();\n\n            let context = TaskContext {\n              storage_dir: storage_dir.to_path_buf(),\n              redis_client: redis_client.clone(),\n              s3_client: s3_client.clone(),\n              pg_pool: pg_pool.clone(),\n              notifier: notifier.clone(),\n              metrics: metrics.clone(),\n              maximum_import_file_size,\n            };\n\n            let handle = spawn_local(async move {\n              consume_task(\n                context,\n                import_task,\n                &stream_name,\n                &group_name,\n                stream_id.id,\n              )\n              .await?;\n              Ok::<(), ImportError>(())\n            });\n            task_handlers.push(handle);\n          },\n          Err(err) => {\n            error!(\"Failed to deserialize task: {:?}\", err);\n          },\n        }\n      }\n    }\n\n    while let Some(result) = task_handlers.next().await {\n      match result {\n        Ok(Ok(())) => {},\n        Ok(Err(e)) => error!(\"Task failed: {:?}\", e),\n        Err(e) => error!(\"Runtime error: {:?}\", e),\n      }\n    }\n  }\n}\n#[derive(Clone)]\nstruct TaskContext {\n  storage_dir: PathBuf,\n  redis_client: ConnectionManager,\n  s3_client: Arc<dyn S3Client>,\n  pg_pool: PgPool,\n  notifier: Arc<dyn ImportNotifier>,\n  metrics: Option<Arc<ImportMetrics>>,\n  maximum_import_file_size: u64,\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn consume_task(\n  mut context: TaskContext,\n  mut import_task: ImportTask,\n  stream_name: &str,\n  group_name: &str,\n  entry_id: String,\n) -> Result<(), ImportError> {\n  if let ImportTask::Notion(task) = &mut import_task {\n    // If no created_at timestamp, proceed directly to processing\n    if task.created_at.is_none() {\n      return process_and_ack_task(context, import_task, stream_name, group_name, &entry_id).await;\n    }\n\n    match task.file_size {\n      None => {\n        return Err(ImportError::UpgradeToLatestVersion(format!(\n          \"Missing file_size for task: {}\",\n          task.task_id\n        )))\n      },\n      Some(file_size) => {\n        if file_size > context.maximum_import_file_size as i64 {\n          let file_size_in_mb = file_size as f64 / 1_048_576.0;\n          let max_size_in_mb = (context.maximum_import_file_size as f64 / 1_048_576.0).ceil();\n          if let Ok(import_record) = select_import_task(&context.pg_pool, &task.task_id).await {\n            handle_failed_task(\n              &mut context,\n              &import_record,\n              task,\n              stream_name,\n              group_name,\n              &entry_id,\n              ImportError::UploadFileTooLarge {\n                file_size_in_mb,\n                max_size_in_mb,\n              },\n              ImportTaskState::Failed,\n            )\n            .await?;\n          }\n\n          return Err(ImportError::UploadFileTooLarge {\n            file_size_in_mb,\n            max_size_in_mb,\n          });\n        }\n      },\n    }\n\n    // Check if the task is expired\n    if let Err(reason) = is_task_expired(task.created_at.unwrap(), task.last_process_at) {\n      if let Ok(import_record) = select_import_task(&context.pg_pool, &task.task_id).await {\n        error!(\"[Import] {} task is expired: {}\", task.workspace_id, reason);\n        handle_failed_task(\n          &mut context,\n          &import_record,\n          task,\n          stream_name,\n          group_name,\n          &entry_id,\n          ImportError::UploadFileExpire,\n          ImportTaskState::Expire,\n        )\n        .await?;\n      }\n      return Ok(());\n    }\n\n    // Check if the blob exists\n    if check_blob_existence(&context.s3_client, &task.s3_key).await? {\n      if task.last_process_at.is_none() {\n        task.last_process_at = Some(Utc::now().timestamp());\n      }\n      process_and_ack_task(context, import_task, stream_name, group_name, &entry_id).await\n    } else {\n      info!(\n        \"[Import] {} zip file not found, queue task\",\n        task.workspace_id\n      );\n      push_task(\n        &mut context.redis_client,\n        stream_name,\n        group_name,\n        import_task,\n        &entry_id,\n      )\n      .await?;\n      Ok(())\n    }\n  } else {\n    // If the task is not a notion task, proceed directly to processing\n    process_and_ack_task(context, import_task, stream_name, group_name, &entry_id).await\n  }\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn handle_failed_task(\n  context: &mut TaskContext,\n  import_record: &AFImportTask,\n  task: &NotionImportTask,\n  stream_name: &str,\n  group_name: &str,\n  entry_id: &str,\n  error: ImportError,\n  task_state: ImportTaskState,\n) -> Result<(), ImportError> {\n  info!(\n    \"[Import]: {} import was failed with reason:{}\",\n    task.workspace_id, error\n  );\n\n  update_import_task_status(&import_record.task_id, task_state, &context.pg_pool)\n    .await\n    .map_err(|e| {\n      error!(\"Failed to update import task status: {:?}\", e);\n      ImportError::Internal(e.into())\n    })?;\n  remove_workspace(&import_record.workspace_id, &context.pg_pool).await;\n  info!(\"[Import]: deleted workspace {}\", task.workspace_id);\n\n  if let Err(err) = context.s3_client.delete_blob(task.s3_key.as_str()).await {\n    error!(\n      \"[Import]: {} failed to delete zip file from S3: {:?}\",\n      task.workspace_id, err\n    );\n  }\n  if let Err(err) = delete_task(&mut context.redis_client, stream_name, group_name, entry_id).await\n  {\n    error!(\n      \"[Import] failed to acknowledge task:{} error:{:?}\",\n      task.workspace_id, err\n    );\n  }\n  notify_user(task, Err(error), context.notifier.clone(), &context.metrics).await?;\n  Ok(())\n}\n\nasync fn check_blob_existence(\n  s3_client: &Arc<dyn S3Client>,\n  s3_key: &str,\n) -> Result<bool, ImportError> {\n  s3_client.is_blob_exist(s3_key).await.map_err(|e| {\n    error!(\"Failed to check blob existence: {:?}\", e);\n    ImportError::Internal(e.into())\n  })\n}\n\nasync fn process_and_ack_task(\n  mut context: TaskContext,\n  import_task: ImportTask,\n  stream_name: &str,\n  group_name: &str,\n  entry_id: &str,\n) -> Result<(), ImportError> {\n  let result = process_task(context.clone(), import_task).await;\n  delete_task(&mut context.redis_client, stream_name, group_name, entry_id)\n    .await\n    .ok();\n  result\n}\n\nfn is_task_expired(created_timestamp: i64, last_process_at: Option<i64>) -> Result<(), String> {\n  match Utc.timestamp_opt(created_timestamp, 0).single() {\n    None => Err(format!(\n      \"[Import] failed to parse timestamp: {}\",\n      created_timestamp\n    )),\n    Some(created_at) => {\n      let now = Utc::now();\n      if created_at > now {\n        return Err(format!(\n          \"[Import] created_at is in the future: {} > {}\",\n          created_at.format(\"%m/%d/%y %H:%M\"),\n          now.format(\"%m/%d/%y %H:%M\")\n        ));\n      }\n\n      let elapsed = now - created_at;\n      let hours = get_env_var(\"APPFLOWY_WORKER_IMPORT_TASK_PROCESS_EXPIRE_HOURS\", \"6\")\n        .parse::<i64>()\n        .unwrap_or(6);\n\n      if elapsed.num_hours() >= hours {\n        return Err(format!(\n          \"task is expired: created_at: {}, last_process_at: {:?}, elapsed: {} hours\",\n          created_at.format(\"%m/%d/%y %H:%M\"),\n          last_process_at,\n          elapsed.num_hours()\n        ));\n      }\n\n      if last_process_at.is_none() {\n        return Ok(());\n      }\n\n      let elapsed = now - created_at;\n      let minutes = get_env_var(\"APPFLOWY_WORKER_IMPORT_TASK_EXPIRE_MINUTES\", \"30\")\n        .parse::<i64>()\n        .unwrap_or(30);\n\n      if elapsed.num_minutes() >= minutes {\n        Err(format!(\n          \"[Import] task is expired: created_at: {}, last_process_at: {:?}, elapsed: {} minutes\",\n          created_at.format(\"%m/%d/%y %H:%M\"),\n          last_process_at,\n          elapsed.num_minutes()\n        ))\n      } else {\n        Ok(())\n      }\n    },\n  }\n}\n\nasync fn push_task(\n  redis_client: &mut ConnectionManager,\n  stream_name: &str,\n  _group_name: &str,\n  task: ImportTask,\n  entry_id: &str,\n) -> Result<(), ImportError> {\n  let task_str = serde_json::to_string(&task).map_err(|e| {\n    error!(\"Failed to serialize task: {:?}\", e);\n    ImportError::Internal(e.into())\n  })?;\n\n  let mut pipeline = redis::pipe();\n  pipeline\n      .atomic() // Ensures the commands are executed atomically\n      .cmd(\"XDEL\") // delete the task\n      .arg(stream_name)\n      .arg(entry_id)\n      .ignore() // Ignore the result of XDEL\n      .cmd(\"XADD\") // Re-add the task to the stream\n      .arg(stream_name)\n      .arg(\"*\")\n      .arg(\"task\")\n      .arg(task_str);\n\n  let result: Result<(), redis::RedisError> = pipeline.query_async(redis_client).await;\n  match result {\n    Ok(_) => Ok(()),\n    Err(err) => {\n      error!(\n        \"Failed to execute transaction for re-adding task: {:?}\",\n        err\n      );\n      Err(ImportError::Internal(err.into()))\n    },\n  }\n}\n\nasync fn delete_task(\n  redis_client: &mut ConnectionManager,\n  stream_name: &str,\n  _group_name: &str,\n  entry_id: &str,\n) -> Result<(), ImportError> {\n  let _: () = redis_client\n    .xdel(stream_name, &[entry_id])\n    .await\n    .map_err(|e| {\n      error!(\"Failed to delete import task: {:?}\", e);\n      ImportError::Internal(e.into())\n    })?;\n  Ok(())\n}\n\nasync fn process_task(\n  mut context: TaskContext,\n  import_task: ImportTask,\n) -> Result<(), ImportError> {\n  let retry_interval: u64 = get_env_var(\"APPFLOWY_WORKER_IMPORT_TASK_RETRY_INTERVAL\", \"10\")\n    .parse()\n    .unwrap_or(10);\n\n  let streaming = get_env_var(\"APPFLOWY_WORKER_IMPORT_TASK_STREAMING\", \"false\")\n    .parse()\n    .unwrap_or(false);\n\n  info!(\"[Import]: Processing task: {}\", import_task);\n\n  match import_task {\n    ImportTask::Notion(task) => {\n      // 1. download zip file\n      let unzip_result = download_and_unzip_file_retry(\n        &context.storage_dir,\n        &task,\n        &context.s3_client,\n        3,\n        Duration::from_secs(retry_interval),\n        streaming,\n        &context.metrics,\n      )\n      .await;\n\n      trace!(\n        \"[Import]: {} download and unzip file result: {:?}\",\n        task.workspace_id,\n        unzip_result\n      );\n      match unzip_result {\n        Ok(unzip_dir_path) => {\n          // 2. process unzip file\n          let result = process_unzip_file(\n            &task,\n            &unzip_dir_path,\n            &context.pg_pool,\n            &mut context.redis_client,\n            &context.s3_client,\n          )\n          .await;\n\n          // If there is any errors when processing the unzip file, we will remove the workspace and notify the user.\n          if result.is_err() {\n            info!(\n              \"[Import]: failed to import notion file, delete workspace:{}\",\n              task.workspace_id\n            );\n            remove_workspace(&task.workspace_id, &context.pg_pool).await;\n          }\n\n          clean_up(&context.s3_client, &task).await;\n          notify_user(&task, result, context.notifier, &context.metrics).await?;\n\n          tokio::spawn(async move {\n            match fs::remove_dir_all(&unzip_dir_path).await {\n              Ok(_) => info!(\n                \"[Import]: {} deleted unzip file: {:?}\",\n                task.workspace_id, unzip_dir_path\n              ),\n              Err(err) => {\n                if err.kind() != ErrorKind::NotFound {\n                  error!(\"Failed to delete unzip file: {:?}\", err);\n                }\n              },\n            }\n          });\n        },\n        Err(err) => {\n          // If there is any errors when download or unzip the file, we will remove the file from S3 and notify the user.\n          if let Err(err) = &context.s3_client.delete_blob(task.s3_key.as_str()).await {\n            error!(\"Failed to delete zip file from S3: {:?}\", err);\n          }\n          remove_workspace(&task.workspace_id, &context.pg_pool).await;\n          clean_up(&context.s3_client, &task).await;\n          notify_user(&task, Err(err), context.notifier, &context.metrics).await?;\n        },\n      }\n\n      Ok(())\n    },\n    ImportTask::Custom(value) => {\n      trace!(\"Custom task: {:?}\", value);\n      let result = ImportResult {\n        user_name: \"\".to_string(),\n        user_email: \"\".to_string(),\n        is_success: true,\n        value: Default::default(),\n      };\n      context\n        .notifier\n        .notify_progress(ImportProgress::Finished(result))\n        .await;\n      Ok(())\n    },\n  }\n}\n/// Retries the download and unzipping of a file from an S3 source.\n///\n/// This function attempts to download a zip file from an S3 bucket and unzip it to a local directory.\n/// If the operation fails, it will retry up to `max_retries` times, waiting for `interval` between each attempt.\n///\npub async fn download_and_unzip_file_retry(\n  storage_dir: &Path,\n  import_task: &NotionImportTask,\n  s3_client: &Arc<dyn S3Client>,\n  max_retries: usize,\n  interval: Duration,\n  streaming: bool,\n  metrics: &Option<Arc<ImportMetrics>>,\n) -> Result<PathBuf, ImportError> {\n  let mut attempt = 0;\n  loop {\n    attempt += 1;\n    match download_and_unzip_file(storage_dir, import_task, s3_client, streaming, metrics).await {\n      Ok(result) => return Ok(result),\n      Err(err) => {\n        // If the Upload file not found error occurs, we will not retry.\n        if matches!(err, ImportError::UploadFileNotFound) {\n          return Err(err);\n        }\n\n        if attempt < max_retries && !err.is_file_not_found() {\n          warn!(\n            \"{} attempt {} failed: {}. Retrying in {:?}...\",\n            import_task.workspace_id, attempt, err, interval\n          );\n          tokio::time::sleep(interval).await;\n        } else {\n          return Err(ImportError::Internal(anyhow!(\n            \"Failed after {} attempts: {}\",\n            attempt,\n            err\n          )));\n        }\n      },\n    }\n  }\n}\n/// Downloads a zip file from S3 and unzips it to the local directory.\n///\n/// This function fetches a zip file from an S3 source using the provided S3 client,\n/// downloads it (if needed), and unzips the contents to the specified local directory.\n///\nasync fn download_and_unzip_file(\n  storage_dir: &Path,\n  import_task: &NotionImportTask,\n  s3_client: &Arc<dyn S3Client>,\n  streaming: bool,\n  metrics: &Option<Arc<ImportMetrics>>,\n) -> Result<PathBuf, ImportError> {\n  let blob_meta = s3_client.get_blob_meta(import_task.s3_key.as_str()).await?;\n  match blob_meta.content_type {\n    None => {\n      error!(\n        \"[Import] {} failed to get content type for file: {:?}\",\n        import_task.workspace_id, import_task.s3_key\n      );\n    },\n    Some(content_type) => {\n      let valid_zip_types = [\n        \"application/zip\",\n        \"application/x-zip-compressed\",\n        \"multipart/x-zip\",\n        \"application/x-compressed\",\n      ];\n\n      if !valid_zip_types.contains(&content_type.as_str()) {\n        return Err(ImportError::Internal(anyhow!(\n          \"Invalid content type: {}\",\n          content_type\n        )));\n      }\n    },\n  }\n\n  let max_content_length = get_env_var(\n    \"APPFLOWY_WORKER_IMPORT_TASK_MAX_FILE_SIZE_BYTES\",\n    MAXIMUM_CONTENT_LENGTH,\n  )\n  .parse::<i64>()\n  .unwrap();\n  if blob_meta.content_length > max_content_length {\n    return Err(ImportError::Internal(anyhow!(\n      \"File size is too large: {} bytes, max allowed: {} bytes\",\n      blob_meta.content_length,\n      max_content_length\n    )));\n  }\n\n  trace!(\n    \"[Import] {} start download file: {:?}, size: {}\",\n    import_task.workspace_id,\n    import_task.s3_key,\n    blob_meta.content_length\n  );\n\n  let S3StreamResponse {\n    stream,\n    content_type: _,\n    content_length,\n  } = s3_client\n    .get_blob_stream(import_task.s3_key.as_str())\n    .await?;\n\n  let buffer_size = buffer_size_from_content_length(content_length);\n  if let Some(metrics) = metrics {\n    metrics.record_import_size_bytes(buffer_size);\n  }\n  if streaming {\n    let zip_reader = get_zip_reader(buffer_size, StreamOrFile::Stream(stream)).await?;\n    let unique_file_name = Uuid::new_v4().to_string();\n    let output_file_path = storage_dir.join(unique_file_name);\n    fs::create_dir_all(&output_file_path)\n      .await\n      .map_err(|err| ImportError::Internal(err.into()))?;\n    fs::set_permissions(&output_file_path, Permissions::from_mode(0o777))\n      .await\n      .map_err(|err| {\n        ImportError::Internal(anyhow!(\"Failed to set permissions for temp dir: {:?}\", err))\n      })?;\n    let unzip_file = async_unzip(\n      zip_reader.inner,\n      output_file_path,\n      Some(import_task.workspace_name.clone()),\n    )\n    .await?;\n    Ok(unzip_file.unzip_dir_path)\n  } else {\n    let file = download_file(\n      &import_task.workspace_id,\n      storage_dir,\n      stream,\n      &import_task.md5_base64,\n    )\n    .await?;\n    trace!(\n      \"[Import] {} start unzip file: {:?}\",\n      import_task.workspace_id,\n      file.path_buf()\n    );\n\n    let file_path = file.path_buf().clone();\n    let storage_dir = storage_dir.to_path_buf();\n    let workspace_name = import_task.workspace_name.clone();\n    let unzip_file =\n      tokio::task::spawn_blocking(move || sync_unzip(file_path, storage_dir, Some(workspace_name)))\n        .await\n        .map_err(|err| ImportError::Internal(err.into()))??;\n\n    info!(\n      \"[Import] {} finish unzip file to dir:{}, file:{:?}\",\n      import_task.workspace_id, unzip_file.dir_name, unzip_file.unzip_dir\n    );\n    Ok(unzip_file.unzip_dir)\n  }\n}\n\nstruct ZipReader {\n  inner: ZipFileReader<Ready<Pin<Box<dyn AsyncBufRead + Unpin + Send>>>>,\n  #[allow(dead_code)]\n  file: Option<AutoRemoveDownloadedFile>,\n}\n\n#[allow(dead_code)]\nenum StreamOrFile {\n  Stream(Box<dyn AsyncBufRead + Unpin + Send>),\n  File(AutoRemoveDownloadedFile),\n}\n\n/// Asynchronously returns a `ZipFileReader` that can read from a stream or a downloaded file, based on the environment setting.\n///\n/// This function checks whether streaming is enabled via the `APPFLOWY_WORKER_IMPORT_TASK_STREAMING` environment variable.\n/// If streaming is enabled, it reads the zip file directly from the provided stream.\n/// Otherwise, it first downloads the zip file to a local file and then reads from it.\n///\nasync fn get_zip_reader(\n  buffer_size: usize,\n  stream_or_file: StreamOrFile,\n) -> Result<ZipReader, ImportError> {\n  match stream_or_file {\n    StreamOrFile::Stream(stream) => {\n      // Occasionally, we encounter the error 'unable to locate the end of central directory record'\n      // when streaming a ZIP file to async-zip. This indicates that the ZIP reader couldn't find\n      // the necessary end-of-file marker. The issue might occur if the entire ZIP file has not been\n      // fully downloaded or buffered before the reader attempts to process the end-of-file information.\n      let reader = futures::io::BufReader::with_capacity(buffer_size, stream);\n      let boxed_reader: Pin<Box<dyn AsyncBufRead + Unpin + Send>> = Box::pin(reader);\n      Ok(ZipReader {\n        inner: async_zip::base::read::stream::ZipFileReader::new(boxed_reader),\n        file: None,\n      })\n    },\n    StreamOrFile::File(file) => {\n      let handle = fs::File::open(&file)\n        .await\n        .map_err(|err| ImportError::Internal(err.into()))?;\n      let reader = tokio::io::BufReader::with_capacity(buffer_size, handle).compat();\n      let boxed_reader: Pin<Box<dyn AsyncBufRead + Unpin + Send>> = Box::pin(reader);\n      Ok(ZipReader {\n        inner: async_zip::base::read::stream::ZipFileReader::new(boxed_reader),\n        // Make sure the lifetime of file is the same as zip reader.\n        file: Some(file),\n      })\n    },\n  }\n}\n\n/// Determines the buffer size based on the content length of the file.\n/// If the buffer is too small, the zip reader will frequently pause to fetch more data,\n/// causing delays. This can make the unzip process appear slower and can even cause premature\n/// errors (like EOF) if there is a delay in fetching more data.\n#[inline]\nfn buffer_size_from_content_length(content_length: Option<i64>) -> usize {\n  match content_length {\n    Some(file_size) => {\n      if file_size < 10 * 1024 * 1024 {\n        3 * 1024 * 1024\n      } else if file_size < 100 * 1024 * 1024 {\n        5 * 1024 * 1024 // 5MB buffer\n      } else {\n        10 * 1024 * 1024 // 10MB buffer\n      }\n    },\n    None => 3 * 1024 * 1024,\n  }\n}\n\nasync fn process_unzip_file(\n  import_task: &NotionImportTask,\n  unzip_dir_path: &PathBuf,\n  pg_pool: &PgPool,\n  redis_client: &mut ConnectionManager,\n  s3_client: &Arc<dyn S3Client>,\n) -> Result<(), ImportError> {\n  let client_id = default_client_id();\n  let _ =\n    Uuid::parse_str(&import_task.workspace_id).map_err(|err| ImportError::Internal(err.into()))?;\n  let notion_importer = NotionImporter::new(\n    import_task.uid,\n    unzip_dir_path,\n    import_task.workspace_id.clone(),\n    import_task.host.clone(),\n  )\n  .map_err(ImportError::ImportCollabError)?;\n\n  trace!(\n    \"[Import]: {} start import notion data\",\n    import_task.workspace_id\n  );\n  let imported = notion_importer\n    .import()\n    .await\n    .map_err(ImportError::ImportCollabError)?;\n  let nested_views = imported.build_nested_views().await;\n  trace!(\n    \"[Import]: {} imported nested views:{}\",\n    import_task.workspace_id,\n    nested_views\n  );\n\n  // 1. Open the workspace folder\n  let workspace_id = Uuid::parse_str(&imported.workspace_id)?;\n  let folder_collab = get_encode_collab_from_bytes(\n    &workspace_id,\n    &workspace_id,\n    &CollabType::Folder,\n    pg_pool,\n    s3_client,\n  )\n  .await?;\n  let mut folder = Folder::from_collab_doc_state(\n    CollabOrigin::Server,\n    folder_collab.into(),\n    &imported.workspace_id,\n    client_id,\n  )\n  .map_err(|err| ImportError::CannotOpenWorkspace(err.to_string()))?;\n\n  // 2. Insert collabs' views into the folder\n  trace!(\n    \"[Import]: {} insert views:{} to folder\",\n    import_task.workspace_id,\n    nested_views.len()\n  );\n  folder.insert_nested_views(nested_views.into_inner(), import_task.uid);\n\n  let mut resources = vec![];\n  let mut collab_params_list = vec![];\n  let mut database_view_ids_by_database_id: HashMap<String, Vec<String>> = HashMap::new();\n  let mut orphan_view_ids = HashSet::new();\n\n  // 3. Collect all collabs and resources\n  let mut stream = imported.into_collab_stream().await;\n  let updated_at = Utc::now();\n  while let Some(imported_collab_info) = stream.next().await {\n    trace!(\n      \"[Import]: {} imported collab: {}\",\n      import_task.workspace_id,\n      imported_collab_info\n    );\n    resources.extend(imported_collab_info.resources);\n    collab_params_list.extend(\n      imported_collab_info\n        .imported_collabs\n        .into_iter()\n        .map(|imported_collab| CollabParams {\n          object_id: imported_collab.object_id.parse().unwrap(),\n          collab_type: imported_collab.collab_type,\n          encoded_collab_v1: Bytes::from(imported_collab.encoded_collab.encode_to_bytes().unwrap()),\n          updated_at: Some(updated_at),\n        })\n        .collect::<Vec<_>>(),\n    );\n\n    match imported_collab_info.import_type {\n      ImportType::Database {\n        database_id,\n        view_ids,\n        row_document_ids,\n      } => {\n        database_view_ids_by_database_id.insert(database_id, view_ids);\n        orphan_view_ids.extend(row_document_ids);\n      },\n      ImportType::Document => {\n        // do nothing\n      },\n    }\n  }\n\n  let w_database_id = select_workspace_database_storage_id(pg_pool, &import_task.workspace_id)\n    .await\n    .map_err(|err| {\n      ImportError::Internal(anyhow!(\n        \"Failed to select workspace database storage id: {:?}\",\n        err\n      ))\n    })?;\n\n  // 4. Edit workspace database collab and then encode workspace database collab\n  if !database_view_ids_by_database_id.is_empty() {\n    let w_db_collab = get_encode_collab_from_bytes(\n      &workspace_id,\n      &w_database_id,\n      &CollabType::WorkspaceDatabase,\n      pg_pool,\n      s3_client,\n    )\n    .await?;\n    let mut w_database = WorkspaceDatabase::from_collab_doc_state(\n      &w_database_id.to_string(),\n      CollabOrigin::Server,\n      w_db_collab.into(),\n      client_id,\n    )\n    .map_err(|err| ImportError::CannotOpenWorkspace(err.to_string()))?;\n    w_database.batch_add_database(database_view_ids_by_database_id);\n\n    let w_database_collab = w_database.encode_collab_v1().map_err(|err| {\n      ImportError::Internal(anyhow!(\n        \"Failed to encode workspace database collab: {:?}\",\n        err\n      ))\n    })?;\n\n    match w_database_collab.encode_to_bytes() {\n      Ok(bytes) => {\n        if let Err(err) = redis_client\n          .set_ex::<String, Vec<u8>, Value>(\n            encode_collab_key(w_database_id.to_string()),\n            bytes,\n            2592000, // WorkspaceDatabase => 1 month\n          )\n          .await\n        {\n          warn!(\n            \"[Import] Failed to insert workspace database to Redis: {}\",\n            err\n          );\n        }\n      },\n      Err(err) => warn!(\n        \"[Import] Failed to encode workspace database collab payload: {}\",\n        err\n      ),\n    }\n\n    trace!(\n      \"[Import]: {} did encode workspace database collab\",\n      import_task.workspace_id\n    );\n    let w_database_collab_params = CollabParams {\n      object_id: w_database_id,\n      collab_type: CollabType::WorkspaceDatabase,\n      encoded_collab_v1: Bytes::from(w_database_collab.encode_to_bytes().unwrap()),\n      updated_at: Some(updated_at),\n    };\n    collab_params_list.push(w_database_collab_params);\n  }\n\n  // 5. Insert orphan view to folder\n  let orphan_views = orphan_view_ids\n    .into_iter()\n    .map(|orphan_view_id| {\n      View::orphan_view(&orphan_view_id, ViewLayout::Document, Some(import_task.uid))\n    })\n    .collect::<Vec<_>>();\n  if !orphan_views.is_empty() {\n    folder.insert_views(orphan_views, import_task.uid);\n  }\n\n  // 6. Encode Folder\n  let folder_collab = folder\n    .encode_collab_v1(|collab| CollabType::Folder.validate_require_data(collab))\n    .map_err(|err| ImportError::Internal(err.into()))?;\n\n  match folder_collab.encode_to_bytes() {\n    Ok(bytes) => {\n      if let Err(err) = redis_client\n        .set_ex::<String, Vec<u8>, Value>(\n          encode_collab_key(&import_task.workspace_id),\n          bytes,\n          604800, // Folder => 1 week\n        )\n        .await\n      {\n        warn!(\"[Import] Failed to insert folder collab to Redis: {}\", err);\n      }\n    },\n    Err(err) => warn!(\"[Import] Failed to encode folder collab payload: {}\", err),\n  }\n\n  let folder_collab_params = CollabParams {\n    object_id: workspace_id,\n    collab_type: CollabType::Folder,\n    encoded_collab_v1: Bytes::from(folder_collab.encode_to_bytes().unwrap()),\n    updated_at: Some(updated_at),\n  };\n  trace!(\n    \"[Import]: {} did encode folder collab\",\n    import_task.workspace_id\n  );\n  collab_params_list.push(folder_collab_params);\n\n  let upload_resources = process_resources(resources).await;\n\n  // 7. Start a transaction to insert all collabs\n  let mut transaction = pg_pool.begin().await.map_err(|err| {\n    ImportError::Internal(anyhow!(\n      \"Failed to start transaction when importing data: {:?}\",\n      err\n    ))\n  })?;\n\n  trace!(\n    \"[Import]: {} insert collabs into database\",\n    import_task.workspace_id\n  );\n\n  // 8. write all collab to disk\n  insert_into_af_collab_bulk_for_user(\n    &mut transaction,\n    &import_task.uid,\n    workspace_id,\n    &collab_params_list,\n  )\n  .await\n  .map_err(|err| {\n    ImportError::Internal(anyhow!(\n      \"Failed to insert collabs into database when importing data: {:?}\",\n      err\n    ))\n  })?;\n\n  trace!(\n    \"[Import]: {} update task:{} status to completed\",\n    import_task.workspace_id,\n    import_task.task_id,\n  );\n  update_import_task_status(\n    &import_task.task_id,\n    ImportTaskState::Completed,\n    transaction.deref_mut(),\n  )\n  .await\n  .map_err(|err| {\n    ImportError::Internal(anyhow!(\n      \"Failed to update import task status when importing data: {:?}\",\n      err\n    ))\n  })?;\n\n  trace!(\n    \"[Import]: {} set is_initialized to true\",\n    import_task.workspace_id,\n  );\n  update_workspace_status(transaction.deref_mut(), &workspace_id, true)\n    .await\n    .map_err(|err| {\n      ImportError::Internal(anyhow!(\n        \"Failed to update workspace status when importing data: {:?}\",\n        err\n      ))\n    })?;\n\n  // Set the workspace's updated_at to the earliest possible timestamp, as it is created by an import task\n  // and not actively updated by a user. This ensures that when sorting workspaces by updated_at to find\n  // the most recent, the imported workspace doesn't appear as the most recently visited workspace.\n  let updated_at = DateTime::from_timestamp(0, 0).unwrap_or_else(Utc::now);\n  update_updated_at_of_workspace_with_uid(\n    transaction.deref_mut(),\n    import_task.uid,\n    &workspace_id,\n    updated_at,\n  )\n  .await\n  .map_err(|err| {\n    ImportError::Internal(anyhow!(\n      \"Failed to update workspace updated_at when importing data: {:?}\",\n      err\n    ))\n  })?;\n\n  // insert metadata into database\n  let metas = upload_resources\n    .iter()\n    .map(|res| res.meta.clone())\n    .collect::<Vec<_>>();\n  let affected_rows = insert_blob_metadata_bulk(transaction.deref_mut(), &workspace_id, metas)\n    .await\n    .map_err(|err| {\n      ImportError::Internal(anyhow!(\n        \"Failed to insert blob metadata into database when importing data: {:?}\",\n        err\n      ))\n    })?;\n\n  if affected_rows != upload_resources.len() as u64 {\n    warn!(\n      \"[Import]: {}, Affected rows: {}, upload resources: {}\",\n      import_task.workspace_id,\n      affected_rows,\n      upload_resources.len()\n    );\n  }\n\n  let result = transaction.commit().await.map_err(|err| {\n    ImportError::Internal(anyhow!(\n      \"Failed to commit transaction when importing data: {:?}\",\n      err\n    ))\n  });\n\n  if result.is_err() {\n    let _: RedisResult<Value> = redis_client.del(encode_collab_key(w_database_id)).await;\n    let _: RedisResult<Value> = redis_client\n      .del(encode_collab_key(&import_task.workspace_id))\n      .await;\n\n    return result;\n  }\n\n  // 9. after inserting all collabs, upload all files to S3\n  trace!(\"[Import]: {} upload files to s3\", import_task.workspace_id,);\n  batch_upload_files_to_s3(&import_task.workspace_id, s3_client, upload_resources)\n    .await\n    .map_err(|err| ImportError::Internal(anyhow!(\"Failed to upload files to S3: {:?}\", err)))?;\n  Ok(())\n}\n\nasync fn clean_up(s3_client: &Arc<dyn S3Client>, task: &NotionImportTask) {\n  if let Err(err) = s3_client.delete_blob(task.s3_key.as_str()).await {\n    error!(\"Failed to delete zip file from S3: {:?}\", err);\n  }\n}\n\nasync fn remove_workspace(workspace_id: &str, pg_pool: &PgPool) {\n  if let Ok(workspace_id) = Uuid::from_str(workspace_id) {\n    if let Err(err) = delete_from_workspace(pg_pool, &workspace_id).await {\n      error!(\n        \"Failed to delete workspace: {:?} when fail to import notion file\",\n        err\n      );\n    }\n  }\n}\n\nasync fn notify_user(\n  import_task: &NotionImportTask,\n  result: Result<(), ImportError>,\n  notifier: Arc<dyn ImportNotifier>,\n  metrics: &Option<Arc<ImportMetrics>>,\n) -> Result<(), ImportError> {\n  let task_id = import_task.task_id.to_string();\n  let (error, error_detail) = match result {\n    Ok(_) => {\n      info!(\"[Import]: successfully imported:{}\", import_task);\n      if let Some(metrics) = metrics {\n        metrics.incr_import_success_count(1);\n      }\n      (None, None)\n    },\n    Err(err) => {\n      error!(\n        \"[Import]: failed to import:{}: error:{:?}\",\n        import_task, err\n      );\n      if let Some(metrics) = metrics {\n        metrics.incr_import_fail_count(1);\n      }\n      let (error, error_detail) = err.report(&task_id);\n      (Some(error), Some(error_detail))\n    },\n  };\n\n  let is_success = error.is_none();\n\n  let value = serde_json::to_value(ImportNotionMailerParam {\n    import_task_id: task_id,\n    user_name: import_task.user_name.clone(),\n    import_file_name: import_task.workspace_name.clone(),\n    workspace_id: import_task.workspace_id.clone(),\n    workspace_name: import_task.workspace_name.clone(),\n    open_workspace: false,\n    error,\n    error_detail,\n  })\n  .unwrap();\n\n  notifier\n    .notify_progress(ImportProgress::Finished(ImportResult {\n      user_name: import_task.user_name.clone(),\n      user_email: import_task.user_email.clone(),\n      is_success,\n      value,\n    }))\n    .await;\n  Ok(())\n}\n\nasync fn batch_upload_files_to_s3(\n  workspace_id: &str,\n  client: &Arc<dyn S3Client>,\n  resources: Vec<UploadCollabResource>,\n) -> Result<(), anyhow::Error> {\n  // Create a stream of upload tasks\n  let upload_stream = stream::iter(resources.into_iter().map(|res| async move {\n    match upload_file_to_s3(\n      client,\n      workspace_id,\n      &res.object_id,\n      &res.meta.file_id,\n      &res.meta.file_type,\n      &res.file_path,\n    )\n    .await\n    {\n      Ok(_) => {\n        trace!(\"Successfully uploaded: {}\", res);\n        Ok(())\n      },\n      Err(e) => {\n        error!(\"Failed to upload {}: {:?}\", res, e);\n        Err(e)\n      },\n    }\n  }))\n  .buffer_unordered(5);\n  let results: Vec<_> = upload_stream.collect().await;\n  let errors: Vec<_> = results.into_iter().filter_map(Result::err).collect();\n\n  if !errors.is_empty() {\n    error!(\"Some uploads failed: {:?}\", errors);\n  }\n\n  Ok(())\n}\n\nasync fn upload_file_to_s3(\n  client: &Arc<dyn S3Client>,\n  workspace_id: &str,\n  object_id: &str,\n  file_id: &str,\n  file_type: &str,\n  file_path: &str,\n) -> Result<(), anyhow::Error> {\n  let path = Path::new(file_path);\n  if !path.exists() {\n    return Err(anyhow!(\"File does not exist: {:?}\", path));\n  }\n\n  let mut attempt = 0;\n  let max_retries = 2;\n\n  let object_key = format!(\"{}/{}/{}\", workspace_id, object_id, file_id);\n  while attempt <= max_retries {\n    let byte_stream = ByteStream::from_path(path).await?;\n    match client\n      .put_blob(&object_key, byte_stream, Some(file_type))\n      .await\n    {\n      Ok(_) => return Ok(()),\n      Err(WorkerError::S3ServiceUnavailable(_)) if attempt < max_retries => {\n        attempt += 1;\n        tokio::time::sleep(Duration::from_secs(3)).await;\n      },\n      Err(err) => return Err(err.into()),\n    }\n  }\n\n  Err(anyhow!(\n    \"Failed to upload file to S3 after {} attempts\",\n    max_retries + 1\n  ))\n}\n\nasync fn get_encode_collab_from_bytes(\n  workspace_id: &Uuid,\n  object_id: &Uuid,\n  collab_type: &CollabType,\n  pg_pool: &PgPool,\n  s3: &Arc<dyn S3Client>,\n) -> Result<EncodedCollab, ImportError> {\n  let key = collab_key(workspace_id, object_id);\n  match s3.get_blob_stream(&key).await {\n    Ok(mut resp) => {\n      let mut buf = Vec::with_capacity(resp.content_length.unwrap_or(1024) as usize);\n      resp\n        .stream\n        .read_to_end(&mut buf)\n        .await\n        .map_err(|err| ImportError::Internal(err.into()))?;\n      let decompressed = zstd::decode_all(&*buf).map_err(|e| ImportError::Internal(e.into()))?;\n      Ok(EncodedCollab {\n        state_vector: Default::default(),\n        doc_state: decompressed.into(),\n        version: EncoderVersion::V1,\n      })\n    },\n    Err(WorkerError::RecordNotFound(_)) => {\n      // fallback to postgres\n      let (_, bytes) = select_blob_from_af_collab(pg_pool, collab_type, object_id)\n        .await\n        .map_err(|err| ImportError::Internal(err.into()))?;\n\n      Ok(\n        EncodedCollab::decode_from_bytes(&bytes)\n          .map_err(|err| ImportError::Internal(err.into()))?,\n      )\n    },\n    Err(err) => Err(err.into()),\n  }\n}\n\n/// Ensure the consumer group exists, if not, create it.\nasync fn ensure_consumer_group(\n  stream_key: &str,\n  group_name: &str,\n  redis_client: &mut ConnectionManager,\n) -> Result<(), WorkerError> {\n  let result: RedisResult<()> = redis_client\n    .xgroup_create_mkstream(stream_key, group_name, \"0\")\n    .await;\n\n  if let Err(redis_error) = result {\n    if let Some(code) = redis_error.code() {\n      if code == \"BUSYGROUP\" {\n        return Ok(());\n      }\n\n      if code == \"NOGROUP\" {\n        return Err(WorkerError::StreamGroupNotExist(group_name.to_string()));\n      }\n    }\n    error!(\"Error when creating consumer group: {:?}\", redis_error);\n    return Err(WorkerError::Internal(redis_error.into()));\n  }\n\n  Ok(())\n}\n\nstruct UnAckTask {\n  stream_id: StreamId,\n  task: ImportTask,\n}\n\nasync fn get_un_ack_tasks(\n  stream_key: &str,\n  group_name: &str,\n  consumer_name: &str,\n  redis_client: &mut ConnectionManager,\n) -> Result<Vec<UnAckTask>, anyhow::Error> {\n  let reply: StreamPendingReply = redis_client.xpending(stream_key, group_name).await?;\n  match reply {\n    StreamPendingReply::Empty => Ok(vec![]),\n    StreamPendingReply::Data(pending) => {\n      let opts = StreamClaimOptions::default()\n        .idle(500)\n        .with_force()\n        .retry(2);\n\n      // If the start_id and end_id are the same, we only need to claim one message.\n      let mut ids = Vec::with_capacity(2);\n      ids.push(pending.start_id.clone());\n      if pending.start_id != pending.end_id {\n        ids.push(pending.end_id);\n      }\n\n      let result: StreamClaimReply = redis_client\n        .xclaim_options(stream_key, group_name, consumer_name, 500, &ids, opts)\n        .await?;\n\n      let tasks = result\n        .ids\n        .into_iter()\n        .filter_map(|stream_id| {\n          ImportTask::try_from(&stream_id)\n            .map(|task| UnAckTask { stream_id, task })\n            .ok()\n        })\n        .collect::<Vec<_>>();\n\n      trace!(\"Claimed tasks: {}\", tasks.len());\n      Ok(tasks)\n    },\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct NotionImportTask {\n  pub uid: i64,\n  pub user_name: String,\n  pub user_email: String,\n  pub task_id: Uuid,\n  pub workspace_id: String,\n  pub workspace_name: String,\n  pub s3_key: String,\n  pub host: String,\n  #[serde(default)]\n  pub created_at: Option<i64>,\n  #[serde(default)]\n  pub md5_base64: Option<String>,\n  #[serde(default)]\n  pub last_process_at: Option<i64>,\n  #[serde(default)]\n  pub file_size: Option<i64>,\n}\n\nimpl Display for NotionImportTask {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    let file_size_mb = self.file_size.map(|size| size as f64 / 1_048_576.0);\n    write!(\n      f,\n      \"NotionImportTask {{ task_id: {}, workspace_id: {}, file_size:{:?}MB, workspace_name: {}, user_name: {}, user_email: {} }}\",\n      self.task_id, self.workspace_id, file_size_mb, self.workspace_name, self.user_name, self.user_email\n    )\n  }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub enum ImportTask {\n  // boxing the large fields to reduce the total size of the enum\n  Notion(Box<NotionImportTask>),\n  Custom(serde_json::Value),\n}\n\nimpl Display for ImportTask {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    match self {\n      ImportTask::Notion(task) => write!(\n        f,\n        \"NotionImportTask {{ workspace_id: {}, workspace_name: {} }}\",\n        task.workspace_id, task.workspace_name\n      ),\n      ImportTask::Custom(value) => write!(f, \"CustomTask {{ {} }}\", value),\n    }\n  }\n}\n\nimpl TryFrom<&StreamId> for ImportTask {\n  type Error = ImportError;\n\n  fn try_from(stream_id: &StreamId) -> Result<Self, Self::Error> {\n    let task_str = match stream_id.map.get(\"task\") {\n      Some(value) => match value {\n        Value::SimpleString(value) => value.to_string(),\n        Value::BulkString(data) => String::from_utf8_lossy(data).to_string(),\n        _ => {\n          error!(\"Unexpected value type for task field: {:?}\", value);\n          return Err(ImportError::Internal(anyhow!(\n            \"Unexpected value type for task field: {:?}\",\n            value\n          )));\n        },\n      },\n      None => {\n        error!(\"Task field not found in Redis stream entry\");\n        return Err(ImportError::Internal(anyhow!(\n          \"Task field not found in Redis stream entry\"\n        )));\n      },\n    };\n\n    from_str::<ImportTask>(&task_str).map_err(|err| ImportError::Internal(err.into()))\n  }\n}\n\nasync fn process_resources(resources: Vec<CollabResource>) -> Vec<UploadCollabResource> {\n  let upload_resources_stream = stream::iter(resources)\n    .flat_map(|resource| {\n      let object_id = resource.object_id.clone();\n      stream::iter(resource.files.into_iter().map(move |file_path| {\n        let object_id = object_id.clone();\n        let path = PathBuf::from(file_path.clone());\n        async move {\n          match insert_meta_from_path(&object_id, &path).await {\n            Ok(meta) => Some(UploadCollabResource {\n              object_id,\n              file_path,\n              meta,\n            }),\n            Err(_) => None,\n          }\n        }\n      }))\n    })\n    // buffer_unordered method limits how many futures (tasks) are run concurrently.\n    .buffer_unordered(20);\n\n  upload_resources_stream\n    .filter_map(|result| async { result })\n    .collect::<Vec<UploadCollabResource>>()\n    .await\n}\n\nstruct UploadCollabResource {\n  object_id: String,\n  file_path: String,\n  meta: BulkInsertMeta,\n}\n\nimpl Display for UploadCollabResource {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    write!(\n      f,\n      \"UploadCollabResource {{ object_id: {}, file_path: {}, file_size: {} }}\",\n      self.object_id, self.file_path, self.meta.file_size\n    )\n  }\n}\n\nasync fn insert_meta_from_path(\n  object_id: &str,\n  path: &PathBuf,\n) -> Result<BulkInsertMeta, ImportError> {\n  let file_id = FileId::from_path(path).await?;\n  let object_id = object_id.to_string();\n  let file_type = mime_guess::from_path(path)\n    .first_or_octet_stream()\n    .to_string();\n  let file_size = fs::metadata(path)\n    .await\n    .map_err(|err| ImportError::Internal(err.into()))?\n    .len() as i64;\n\n  Ok(BulkInsertMeta {\n    object_id,\n    file_id,\n    file_type,\n    file_size,\n  })\n}\n\nfn collab_key(workspace_id: &Uuid, object_id: &Uuid) -> String {\n  format!(\n    \"collabs/{}/{}/encoded_collab.v1.zstd\",\n    workspace_id, object_id\n  )\n}\n\nfn encode_collab_key<T: Display>(object_id: T) -> String {\n  format!(\"encode_collab_v0:{}\", object_id)\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/indexer_worker/mod.rs",
    "content": "mod worker;\npub use worker::*;\n"
  },
  {
    "path": "services/appflowy-worker/src/indexer_worker/worker.rs",
    "content": "use app_error::AppError;\nuse database::index::{get_collab_embedding_fragment_ids, get_collabs_indexed_at};\nuse indexer::collab_indexer::IndexerProvider;\nuse indexer::entity::EmbeddingRecord;\nuse indexer::error::IndexerError;\nuse indexer::metrics::EmbeddingMetrics;\nuse indexer::queue::{\n  ack_task, default_indexer_group_option, ensure_indexer_consumer_group,\n  read_background_embed_tasks,\n};\nuse indexer::scheduler::{spawn_pg_write_embeddings, UnindexedCollabTask, UnindexedData};\nuse indexer::vector::embedder::{AFEmbedder, AzureConfig, OpenAIConfig};\nuse indexer::vector::open_ai;\nuse redis::aio::ConnectionManager;\nuse sqlx::PgPool;\nuse std::sync::Arc;\nuse std::time::{Duration, Instant};\nuse tokio::sync::mpsc::{unbounded_channel, UnboundedSender};\nuse tokio::sync::RwLock;\nuse tokio::task::JoinSet;\nuse tokio::time::{interval, MissedTickBehavior};\nuse tracing::{error, info, trace, warn};\n\npub struct BackgroundIndexerConfig {\n  pub enable: bool,\n  pub open_ai_config: Option<OpenAIConfig>,\n  pub azure_ai_config: Option<AzureConfig>,\n  pub tick_interval_secs: u64,\n}\n\npub async fn run_background_indexer(\n  pg_pool: PgPool,\n  mut redis_client: ConnectionManager,\n  embed_metrics: Arc<EmbeddingMetrics>,\n  config: BackgroundIndexerConfig,\n) {\n  if !config.enable {\n    info!(\"Background indexer is disabled. Stop background indexer\");\n    return;\n  }\n\n  if config.open_ai_config.is_none() && config.azure_ai_config.is_none() {\n    error!(\"OpenAI API key is not set. Stop background indexer\");\n    return;\n  }\n\n  let indexer_provider = IndexerProvider::new();\n  info!(\"Starting background indexer...\");\n  if let Err(err) = ensure_indexer_consumer_group(&mut redis_client).await {\n    error!(\"Failed to ensure indexer consumer group: {:?}\", err);\n  }\n\n  let latest_write_embedding_err = Arc::new(RwLock::new(None));\n  let (write_embedding_tx, write_embedding_rx) = unbounded_channel::<EmbeddingRecord>();\n  let write_embedding_task_fut = spawn_pg_write_embeddings(\n    write_embedding_rx,\n    pg_pool.clone(),\n    embed_metrics.clone(),\n    latest_write_embedding_err.clone(),\n  );\n\n  let process_tasks_task_fut = process_upcoming_tasks(\n    pg_pool,\n    &mut redis_client,\n    embed_metrics,\n    indexer_provider,\n    config,\n    write_embedding_tx,\n    latest_write_embedding_err,\n  );\n\n  tokio::select! {\n    _ = write_embedding_task_fut => {\n      error!(\"[Background Embedding] Write embedding task stopped\");\n    },\n    _ = process_tasks_task_fut => {\n      error!(\"[Background Embedding] Process tasks task stopped\");\n    },\n  }\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn process_upcoming_tasks(\n  pg_pool: PgPool,\n  redis_client: &mut ConnectionManager,\n  metrics: Arc<EmbeddingMetrics>,\n  indexer_provider: Arc<IndexerProvider>,\n  config: BackgroundIndexerConfig,\n  sender: UnboundedSender<EmbeddingRecord>,\n  latest_write_embedding_err: Arc<RwLock<Option<AppError>>>,\n) {\n  let options = default_indexer_group_option(50);\n  let mut interval = interval(Duration::from_secs(config.tick_interval_secs));\n  interval.set_missed_tick_behavior(MissedTickBehavior::Skip);\n  interval.tick().await;\n\n  loop {\n    interval.tick().await;\n\n    let latest_error = latest_write_embedding_err.write().await.take();\n    if let Some(err) = latest_error {\n      if matches!(err, AppError::ActionTimeout(_)) {\n        info!(\n          \"[Background Embedding] last write embedding task failed with timeout, waiting for 30s before retrying...\"\n        );\n        tokio::time::sleep(Duration::from_secs(15)).await;\n      }\n    }\n\n    match read_background_embed_tasks(redis_client, &options).await {\n      Ok(replay) => {\n        let all_keys: Vec<String> = replay\n          .keys\n          .iter()\n          .flat_map(|key| key.ids.iter().map(|stream_id| stream_id.id.clone()))\n          .collect();\n\n        for key in replay.keys {\n          info!(\n            \"[Background Embedding] processing {} embedding tasks\",\n            key.ids.len()\n          );\n\n          let mut tasks: Vec<UnindexedCollabTask> = key\n            .ids\n            .into_iter()\n            .filter_map(|stream_id| UnindexedCollabTask::try_from(&stream_id).ok())\n            .collect();\n          tasks.retain(|task| !task.data.is_empty());\n\n          let collab_ids: Vec<_> = tasks.iter().map(|task| task.object_id).collect();\n\n          let indexed_collabs = get_collabs_indexed_at(&pg_pool, collab_ids.clone())\n            .await\n            .unwrap_or_default();\n\n          let all_tasks_len = tasks.len();\n          if !indexed_collabs.is_empty() {\n            // Filter out tasks where `created_at` is less than `indexed_at`\n            tasks.retain(|task| {\n              indexed_collabs\n                .get(&task.object_id)\n                .is_none_or(|indexed_at| task.created_at > indexed_at.timestamp())\n            });\n          }\n\n          if all_tasks_len != tasks.len() {\n            info!(\"[Background Embedding] filter out {} tasks where `created_at` is less than `indexed_at`\", all_tasks_len - tasks.len());\n          }\n\n          let start = Instant::now();\n          let num_tasks = tasks.len();\n          let existing_embeddings = get_collab_embedding_fragment_ids(&pg_pool, collab_ids)\n            .await\n            .unwrap_or_default();\n          let mut join_set = JoinSet::new();\n          for task in tasks {\n            if let Some(indexer) = indexer_provider.indexer_for(task.collab_type) {\n              if let Ok(embedder) = create_embedder(&config) {\n                trace!(\n                  \"[Background Embedding] processing task: {}, content:{:?}, collab_type: {}\",\n                  task.object_id,\n                  task.data,\n                  task.collab_type\n                );\n                let paragraphs = match task.data {\n                  UnindexedData::Paragraphs(paragraphs) => paragraphs,\n                  UnindexedData::Text(text) => text.split('\\n').map(|s| s.to_string()).collect(),\n                };\n                let mut chunks = match indexer.create_embedded_chunks_from_text(\n                  task.object_id,\n                  paragraphs,\n                  embedder.model(),\n                ) {\n                  Ok(chunks) => chunks,\n                  Err(err) => {\n                    warn!(\n                    \"[Background Embedding] failed to create embedded chunks for task: {}, error: {:?}\",\n                    task.object_id,\n                    err\n                  );\n                    continue;\n                  },\n                };\n                if let Some(existing_chunks) = existing_embeddings.get(&task.object_id) {\n                  for chunk in chunks.iter_mut() {\n                    if existing_chunks.contains(&chunk.fragment_id) {\n                      chunk.content = None; // Clear content to mark unchanged chunk\n                      chunk.embedding = None;\n                    }\n                  }\n                }\n                join_set.spawn(async move {\n                  let embeddings = indexer.embed(&embedder, chunks).await?;\n                  Ok::<_, AppError>(embeddings.map(|embeddings| EmbeddingRecord {\n                    workspace_id: task.workspace_id,\n                    object_id: task.object_id,\n                    collab_type: task.collab_type,\n                    tokens_used: embeddings.tokens_consumed,\n                    chunks: embeddings.chunks,\n                  }))\n                });\n              }\n            }\n          }\n\n          while let Some(Ok(result)) = join_set.join_next().await {\n            match result {\n              Err(_) => {\n                metrics.record_failed_embed_count(1);\n              },\n              Ok(None) => {},\n              Ok(Some(record)) => {\n                metrics.record_embed_count(1);\n                trace!(\n                  \"[Background Embedding] send {} embedding record to write task\",\n                  record.object_id\n                );\n                if let Err(err) = sender.send(record) {\n                  trace!(\n                    \"[Background Embedding] failed to send embedding record to write task: {:?}\",\n                    err\n                  );\n                }\n              },\n            }\n          }\n\n          let cost = start.elapsed().as_millis();\n          metrics.record_gen_embedding_time(num_tasks as u32, cost);\n        }\n\n        if !all_keys.is_empty() {\n          match ack_task(redis_client, all_keys, true).await {\n            Ok(_) => trace!(\"[Background embedding]: delete tasks from stream\"),\n            Err(err) => {\n              error!(\"[Background Embedding] Failed to ack tasks: {:?}\", err);\n            },\n          }\n        }\n      },\n      Err(err) => {\n        error!(\"[Background Embedding] Failed to read tasks: {:?}\", err);\n        if matches!(err, IndexerError::StreamGroupNotExist(_)) {\n          if let Err(err) = ensure_indexer_consumer_group(redis_client).await {\n            error!(\n              \"[Background Embedding] Failed to ensure indexer consumer group: {:?}\",\n              err\n            );\n          }\n        }\n      },\n    }\n  }\n}\n\nfn create_embedder(config: &BackgroundIndexerConfig) -> Result<AFEmbedder, AppError> {\n  if let Some(config) = &config.azure_ai_config {\n    return Ok(AFEmbedder::AzureOpenAI(open_ai::AzureOpenAIEmbedder::new(\n      config.clone(),\n    )));\n  }\n\n  if let Some(config) = &config.open_ai_config {\n    return Ok(AFEmbedder::OpenAI(open_ai::OpenAIEmbedder::new(\n      config.clone(),\n    )));\n  }\n\n  Err(AppError::AIServiceUnavailable(\n    \"No embedder available\".to_string(),\n  ))\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/lib.rs",
    "content": "pub mod error;\npub mod import_worker;\npub mod indexer_worker;\nmod mailer;\npub mod metric;\npub mod s3_client;\n"
  },
  {
    "path": "services/appflowy-worker/src/mailer.rs",
    "content": "use mailer::sender::Mailer;\nuse std::ops::Deref;\n\npub const IMPORT_SUCCESS_TEMPLATE: &str = \"import_notion_success\";\npub const IMPORT_FAIL_TEMPLATE: &str = \"import_notion_fail\";\n#[derive(Clone)]\npub struct AFWorkerMailer(Mailer);\n\nimpl Deref for AFWorkerMailer {\n  type Target = Mailer;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl AFWorkerMailer {\n  pub async fn new(mut mailer: Mailer) -> Result<Self, anyhow::Error> {\n    let import_data_success =\n      include_str!(\"../../../assets/mailer_templates/build_production/import_data_success.html\");\n\n    let import_data_fail =\n      include_str!(\"../../../assets/mailer_templates/build_production/import_data_fail.html\");\n\n    for (name, template) in [\n      (IMPORT_SUCCESS_TEMPLATE, import_data_success),\n      (IMPORT_FAIL_TEMPLATE, import_data_fail),\n    ] {\n      mailer\n        .register_template(name, template)\n        .await\n        .map_err(|err| {\n          anyhow::anyhow!(format!(\"Failed to register handlebars template: {}\", err))\n        })?;\n    }\n\n    Ok(Self(mailer))\n  }\n}\n\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\npub struct ImportNotionMailerParam {\n  pub import_task_id: String,\n  pub user_name: String,\n  pub import_file_name: String,\n  pub workspace_id: String,\n  pub workspace_name: String,\n  pub open_workspace: bool,\n  pub error: Option<String>,\n  pub error_detail: Option<String>,\n}\n\n#[cfg(test)]\nmod tests {\n  use crate::mailer::{AFWorkerMailer, ImportNotionMailerParam, IMPORT_SUCCESS_TEMPLATE};\n  use mailer::sender::Mailer;\n\n  #[tokio::test]\n  async fn render_import_report() {\n    let mailer = Mailer::new(\n      \"smtp_username\".to_string(),\n      \"stmp_email\".to_string(),\n      \"smtp_password\".to_string().into(),\n      \"localhost\",\n      465,\n      \"none\",\n    )\n    .await\n    .unwrap();\n    let worker_mailer = AFWorkerMailer::new(mailer).await.unwrap();\n    let value = serde_json::to_value(ImportNotionMailerParam {\n      import_task_id: \"test_task_id\".to_string(),\n      user_name: \"nathan\".to_string(),\n      import_file_name: \"working\".to_string(),\n      workspace_id: \"1\".to_string(),\n      workspace_name: \"working\".to_string(),\n      open_workspace: true,\n      error: None,\n      error_detail: None,\n    })\n    .unwrap();\n    let s = worker_mailer\n      .render(IMPORT_SUCCESS_TEMPLATE, &value)\n      .unwrap();\n\n    println!(\"{}\", s);\n  }\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/main.rs",
    "content": "mod application;\nmod config;\npub mod error;\npub mod import_worker;\npub(crate) mod s3_client;\n\nmod metric;\n\nmod mailer;\nuse crate::application::run_server;\nuse crate::config::Config;\nuse tokio::net::TcpListener;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n  dotenvy::dotenv().ok();\n\n  let listener = TcpListener::bind(\"[::]:4001\").await.unwrap();\n  let config = Config::from_env()?;\n  run_server(listener, config).await\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/metric.rs",
    "content": "use prometheus_client::metrics::gauge::Gauge;\nuse prometheus_client::metrics::histogram::{exponential_buckets, Histogram};\nuse prometheus_client::registry::Registry;\n\npub struct ImportMetrics {\n  pub update_size_bytes: Histogram,\n  pub import_success_count: Gauge,\n  pub import_fail_count: Gauge,\n}\n\nimpl ImportMetrics {\n  pub fn init() -> Self {\n    let update_size_buckets = exponential_buckets(1024.0, 2.0, 10);\n    Self {\n      update_size_bytes: Histogram::new(update_size_buckets),\n      import_success_count: Default::default(),\n      import_fail_count: Default::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::init();\n    let web_update_registry = registry.sub_registry_with_prefix(\"appflowy_web\");\n    web_update_registry.register(\n      \"import_payload_size_bytes\",\n      \"Size of the update in bytes\",\n      metrics.update_size_bytes.clone(),\n    );\n    web_update_registry.register(\n      \"import_success_count\",\n      \"import success count\",\n      metrics.import_success_count.clone(),\n    );\n    web_update_registry.register(\n      \"import_fail_count\",\n      \"import fail count\",\n      metrics.import_fail_count.clone(),\n    );\n    metrics\n  }\n\n  pub fn record_import_size_bytes(&self, size: usize) {\n    self.update_size_bytes.observe(size as f64);\n  }\n\n  pub fn incr_import_success_count(&self, count: i64) {\n    self.import_success_count.inc_by(count);\n  }\n\n  pub fn incr_import_fail_count(&self, count: i64) {\n    self.import_fail_count.inc_by(count);\n  }\n}\n"
  },
  {
    "path": "services/appflowy-worker/src/s3_client.rs",
    "content": "use crate::error::WorkerError;\nuse anyhow::{anyhow, Context};\nuse aws_sdk_s3::error::SdkError;\nuse std::fs::Permissions;\n\nuse anyhow::Result;\nuse aws_sdk_s3::operation::get_object::GetObjectError;\nuse aws_sdk_s3::operation::head_object::{HeadObjectError, HeadObjectOutput};\nuse aws_sdk_s3::primitives::ByteStream;\nuse axum::async_trait;\nuse base64::engine::general_purpose::STANDARD;\nuse base64::Engine;\nuse futures::AsyncReadExt;\nuse std::ops::Deref;\nuse std::os::unix::fs::PermissionsExt;\nuse std::path::{Path, PathBuf};\nuse tokio::fs;\nuse tokio::fs::OpenOptions;\nuse tokio::io::AsyncWriteExt;\nuse tokio_util::compat::TokioAsyncReadCompatExt;\nuse tracing::{error, trace};\nuse uuid::Uuid;\n\n#[async_trait]\npub trait S3Client: Send + Sync {\n  async fn get_blob_stream(&self, object_key: &str) -> Result<S3StreamResponse, WorkerError>;\n  async fn put_blob(\n    &self,\n    object_key: &str,\n    content: ByteStream,\n    content_type: Option<&str>,\n  ) -> Result<(), WorkerError>;\n  async fn delete_blob(&self, object_key: &str) -> Result<(), WorkerError>;\n\n  async fn is_blob_exist(&self, object_key: &str) -> Result<bool, WorkerError>;\n  async fn get_blob_meta(&self, object_key: &str) -> Result<BlobMeta, WorkerError>;\n}\n\npub struct BlobMeta {\n  pub content_length: i64,\n  pub content_type: Option<String>,\n}\n\n#[derive(Clone, Debug)]\npub struct S3ClientImpl {\n  pub inner: aws_sdk_s3::Client,\n  pub bucket: String,\n}\n\nimpl S3ClientImpl {\n  async fn get_head_object(&self, object_key: &str) -> Result<HeadObjectOutput, WorkerError> {\n    self\n      .inner\n      .head_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n      .map_err(|err| match err {\n        SdkError::ServiceError(service_err) => match service_err.err() {\n          HeadObjectError::NotFound(_) => WorkerError::RecordNotFound(\"blob not found\".to_string()),\n          _ => WorkerError::from(anyhow!(\"Failed to head object from S3: {:?}\", service_err)),\n        },\n        _ => WorkerError::from(anyhow!(\"Failed to head object from S3: {}\", err)),\n      })\n  }\n}\n\nimpl Deref for S3ClientImpl {\n  type Target = aws_sdk_s3::Client;\n\n  fn deref(&self) -> &Self::Target {\n    &self.inner\n  }\n}\n\n#[async_trait]\nimpl S3Client for S3ClientImpl {\n  async fn get_blob_stream(&self, object_key: &str) -> Result<S3StreamResponse, WorkerError> {\n    match self\n      .inner\n      .get_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n    {\n      Ok(output) => {\n        let stream = output.body.into_async_read().compat();\n        let content_type = output.content_type;\n        let content_length = output.content_length;\n\n        trace!(\n          \"get object from S3: {} ({:?} bytes)\",\n          object_key,\n          content_length\n        );\n\n        Ok(S3StreamResponse {\n          stream: Box::new(stream),\n          content_type,\n          content_length,\n        })\n      },\n      Err(SdkError::ServiceError(service_err)) => match service_err.err() {\n        GetObjectError::NoSuchKey(_) => Err(WorkerError::RecordNotFound(format!(\n          \"blob not found for key:{object_key}\"\n        ))),\n        _ => Err(WorkerError::from(anyhow!(\n          \"Failed to get object from S3: {:?}\",\n          service_err\n        ))),\n      },\n      Err(err) => Err(WorkerError::from(anyhow!(\n        \"Failed to get object from S3: {}\",\n        err\n      ))),\n    }\n  }\n\n  async fn put_blob(\n    &self,\n    object_key: &str,\n    content: ByteStream,\n    content_type: Option<&str>,\n  ) -> Result<(), WorkerError> {\n    match self\n      .inner\n      .put_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .body(content)\n      .content_type(content_type.unwrap_or(\"application/octet-stream\"))\n      .send()\n      .await\n    {\n      Ok(_) => {\n        trace!(\"put object to S3: {}\", object_key);\n        Ok(())\n      },\n      Err(err) => match err {\n        SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) | SdkError::ServiceError(_) => {\n          Err(WorkerError::S3ServiceUnavailable(format!(\n            \"Failed to upload object to S3: {}\",\n            err\n          )))\n        },\n        _ => Err(WorkerError::Internal(anyhow!(\n          \"Failed to upload object to S3: {}\",\n          err\n        ))),\n      },\n    }\n  }\n\n  async fn delete_blob(&self, object_key: &str) -> Result<(), WorkerError> {\n    trace!(\"Deleting object from S3: {}\", object_key);\n    match self\n      .inner\n      .delete_object()\n      .bucket(&self.bucket)\n      .key(object_key)\n      .send()\n      .await\n    {\n      Ok(_) => {\n        trace!(\"deleted object from S3: {}\", object_key);\n        Ok(())\n      },\n      Err(SdkError::ServiceError(service_err)) => Err(WorkerError::from(anyhow!(\n        \"Failed to delete object from S3: {:?}\",\n        service_err\n      ))),\n      Err(err) => Err(WorkerError::from(anyhow!(\n        \"Failed to delete object from S3: {}\",\n        err\n      ))),\n    }\n  }\n\n  async fn is_blob_exist(&self, object_key: &str) -> Result<bool, WorkerError> {\n    let result = self.get_head_object(object_key).await;\n    match result {\n      Ok(_) => Ok(true),\n      Err(err) => match err {\n        WorkerError::RecordNotFound(_) => Ok(false),\n        _ => Err(err),\n      },\n    }\n  }\n\n  async fn get_blob_meta(&self, object_key: &str) -> Result<BlobMeta, WorkerError> {\n    let output = self.get_head_object(object_key).await?;\n    let content_length = output.content_length.unwrap_or(0);\n    let content_type = output.content_type;\n    Ok(BlobMeta {\n      content_length,\n      content_type,\n    })\n  }\n}\n\npub struct S3StreamResponse {\n  pub stream: Box<dyn futures::AsyncBufRead + Unpin + Send>,\n  pub content_type: Option<String>,\n  pub content_length: Option<i64>,\n}\n\npub struct AutoRemoveDownloadedFile {\n  zip_file_path: PathBuf,\n  pub(crate) workspace_id: String,\n}\n\nimpl AutoRemoveDownloadedFile {\n  pub fn path_buf(&self) -> &PathBuf {\n    &self.zip_file_path\n  }\n}\n\nimpl AsRef<Path> for AutoRemoveDownloadedFile {\n  fn as_ref(&self) -> &Path {\n    &self.zip_file_path\n  }\n}\n\nimpl AsRef<PathBuf> for AutoRemoveDownloadedFile {\n  fn as_ref(&self) -> &PathBuf {\n    &self.zip_file_path\n  }\n}\n\nimpl Deref for AutoRemoveDownloadedFile {\n  type Target = PathBuf;\n\n  fn deref(&self) -> &Self::Target {\n    &self.zip_file_path\n  }\n}\n\nimpl Drop for AutoRemoveDownloadedFile {\n  fn drop(&mut self) {\n    let path = self.zip_file_path.clone();\n    let _workspace_id = self.workspace_id.clone();\n    tokio::spawn(async move {\n      if path.exists() {\n        if let Err(err) = fs::remove_file(&path).await {\n          error!(\n            \"Failed to delete the auto remove downloaded file: {:?}, error: {}\",\n            path, err\n          )\n        }\n      }\n    });\n  }\n}\n\npub async fn download_file(\n  workspace_id: &str,\n  storage_dir: &Path,\n  stream: Box<dyn futures::AsyncBufRead + Unpin + Send>,\n  expected_md5_base64: &Option<String>,\n) -> Result<AutoRemoveDownloadedFile, anyhow::Error> {\n  let zip_file_dir = storage_dir.join(format!(\"{}\", Uuid::new_v4()));\n  if !zip_file_dir.exists() {\n    fs::create_dir_all(&zip_file_dir).await?;\n    let file_permissions = Permissions::from_mode(0o777);\n    fs::set_permissions(&zip_file_dir, file_permissions).await?;\n  }\n\n  let zip_file_path = zip_file_dir.join(\"file.zip\");\n  trace!(\n    \"[Import] {} start to write stream to file: {:?}\",\n    workspace_id,\n    zip_file_path\n  );\n  write_stream_to_file(&zip_file_path, expected_md5_base64, stream).await?;\n  trace!(\n    \"[Import] {} finish writing stream to file: {:?}\",\n    workspace_id,\n    zip_file_path\n  );\n  Ok(AutoRemoveDownloadedFile {\n    zip_file_path,\n    workspace_id: workspace_id.to_string(),\n  })\n}\n\npub async fn write_stream_to_file(\n  file_path: &PathBuf,\n  expected_md5_base64: &Option<String>,\n  mut stream: Box<dyn futures::AsyncBufRead + Unpin + Send>,\n) -> Result<(), anyhow::Error> {\n  let mut context = md5::Context::new();\n  let mut file = OpenOptions::new()\n    .write(true)\n    .create(true)\n    .truncate(true)\n    .mode(0o644)\n    .open(file_path)\n    .await\n    .map_err(|err| anyhow!(\"Failed to create file with permissions: {:?}\", err))?;\n  let mut buffer = vec![0u8; 1_048_576];\n  loop {\n    let bytes_read = stream.read(&mut buffer).await?;\n    if bytes_read == 0 {\n      break;\n    }\n    context.consume(&buffer[..bytes_read]);\n    file\n      .write_all(&buffer[..bytes_read])\n      .await\n      .with_context(|| format!(\"Failed to write data to file: {:?}\", file_path.as_os_str()))?;\n  }\n\n  let digest = context.compute();\n  let md5_base64 = STANDARD.encode(digest.as_ref());\n  if let Some(expected_md5) = expected_md5_base64 {\n    if md5_base64 != *expected_md5 {\n      error!(\n        \"[Import]: MD5 mismatch, expected: {}, current: {}\",\n        expected_md5, md5_base64\n      );\n      return Err(anyhow!(\"MD5 mismatch\"));\n    }\n  }\n\n  file\n    .flush()\n    .await\n    .with_context(|| format!(\"Failed to flush data to file: {:?}\", file_path.as_os_str()))?;\n  Ok(())\n}\n"
  },
  {
    "path": "services/appflowy-worker/tests/import_test.rs",
    "content": "use anyhow::Result;\nuse appflowy_worker::error::WorkerError;\nuse appflowy_worker::import_worker::report::{ImportNotifier, ImportProgress};\nuse appflowy_worker::import_worker::worker::{run_import_worker, ImportTask};\nuse appflowy_worker::s3_client::{BlobMeta, S3Client, S3StreamResponse};\nuse aws_sdk_s3::primitives::ByteStream;\nuse axum::async_trait;\n\nuse redis::aio::ConnectionManager;\nuse redis::AsyncCommands;\nuse redis::RedisResult;\nuse serde_json::json;\nuse sqlx::PgPool;\nuse sqlx::__rt::timeout;\nuse std::sync::{Arc, Once};\nuse std::time::Duration;\nuse tokio::runtime::Builder;\nuse tokio::task::LocalSet;\n\nuse tracing_subscriber::fmt::Subscriber;\nuse tracing_subscriber::util::SubscriberInitExt;\nuse tracing_subscriber::EnvFilter;\n\n#[sqlx::test(migrations = false)]\nasync fn create_custom_task_test(pg_pool: PgPool) {\n  let redis_client = redis_connection_manager().await;\n  let stream_name = uuid::Uuid::new_v4().to_string();\n  let notifier = Arc::new(MockNotifier::new());\n  let mut task_provider = MockTaskProvider::new(redis_client.clone(), stream_name.clone());\n  let _ = run_importer_worker(\n    pg_pool,\n    redis_client.clone(),\n    notifier.clone(),\n    stream_name,\n    3,\n  );\n\n  let mut task_workspace_ids = vec![];\n  // generate 5 tasks\n  for _ in 0..5 {\n    let workspace_id = uuid::Uuid::new_v4().to_string();\n    task_workspace_ids.push(workspace_id.clone());\n    task_provider\n      .create_task(ImportTask::Custom(json!({\"workspace_id\": workspace_id})))\n      .await;\n  }\n\n  let mut rx = notifier.subscribe();\n  timeout(Duration::from_secs(30), async {\n    while let Ok(task) = rx.recv().await {\n      task_workspace_ids.retain(|_id| {\n        if let ImportProgress::Finished(_result) = &task {\n          return false;\n        }\n        true\n      });\n\n      if task_workspace_ids.is_empty() {\n        break;\n      }\n    }\n  })\n  .await\n  .unwrap();\n}\n\n// #[tokio::test]\n// async fn consume_group_task_test() {\n//   let mut redis_client = redis_client().await;\n//   let stream_name = format!(\"import_task_stream_{}\", uuid::Uuid::new_v4());\n//   let consumer_group = \"import_task_group\";\n//   let consumer_name = \"appflowy_worker\";\n//   let workspace_id = uuid::Uuid::new_v4().to_string();\n//   let user_uuid = uuid::Uuid::new_v4().to_string();\n//\n//   let _: RedisResult<()> = redis_client.xgroup_create_mkstream(&stream_name, consumer_group, \"0\");\n//   // 1. insert a task\n//   let task = json!({\n//       \"notion\": {\n//          \"uid\": 1,\n//          \"user_uuid\": user_uuid,\n//          \"workspace_id\": workspace_id,\n//          \"s3_key\": workspace_id,\n//          \"file_type\": \"zip\",\n//          \"host\": \"http::localhost\",\n//       }\n//   });\n//\n//   let _: () = redis_client\n//     .xadd(&stream_name, \"*\", &[(\"task\", task.to_string())])\n//     .unwrap();\n//   tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;\n//\n//   // 2. consume a task\n//   let options = StreamReadOptions::default()\n//     .group(consumer_group, consumer_name)\n//     .count(3);\n//\n//   let tasks: StreamReadReply = redis_client\n//     .xread_options(&[&stream_name], &[\">\"], &options)\n//     .unwrap();\n//   assert!(!tasks.keys.is_empty());\n//\n//   for stream_key in tasks.keys {\n//     for stream_id in stream_key.ids {\n//       let task_str = match stream_id.map.get(\"task\") {\n//         Some(value) => match value {\n//           Value::Data(data) => String::from_utf8_lossy(data).to_string(),\n//           _ => panic!(\"Task field is not a string\"),\n//         },\n//         None => continue,\n//       };\n//\n//       let _ = from_str::<ImportTask>(&task_str).unwrap();\n//       let _: () = redis_client\n//         .xack(&stream_name, consumer_group, &[stream_id.id.clone()])\n//         .unwrap();\n//     }\n//   }\n// }\n\npub async fn redis_connection_manager() -> redis::aio::ConnectionManager {\n  let redis_uri = \"redis://localhost:6379\";\n  redis::Client::open(redis_uri)\n    .expect(\"failed to create redis client\")\n    .get_connection_manager()\n    .await\n    .expect(\"failed to get redis connection manager\")\n}\n\nfn run_importer_worker(\n  pg_pool: PgPool,\n  redis_client: ConnectionManager,\n  notifier: Arc<dyn ImportNotifier>,\n  stream_name: String,\n  tick_interval_secs: u64,\n) -> std::thread::JoinHandle<()> {\n  setup_log();\n  let max_import_file_size = 1_000_000_000;\n\n  std::thread::spawn(move || {\n    let runtime = Builder::new_current_thread().enable_all().build().unwrap();\n    let local_set = LocalSet::new();\n    let import_worker_fut = local_set.run_until(run_import_worker(\n      pg_pool,\n      redis_client,\n      None,\n      Arc::new(MockS3Client),\n      notifier,\n      &stream_name,\n      tick_interval_secs,\n      max_import_file_size,\n    ));\n    runtime.block_on(import_worker_fut).unwrap();\n  })\n}\n\nstruct MockTaskProvider {\n  redis_client: ConnectionManager,\n  stream_name: String,\n}\n\nimpl MockTaskProvider {\n  fn new(redis_client: ConnectionManager, stream_name: String) -> Self {\n    Self {\n      redis_client,\n      stream_name,\n    }\n  }\n\n  async fn create_task(&mut self, task: ImportTask) {\n    let task = serde_json::to_string(&task).unwrap();\n    let result: RedisResult<()> = self\n      .redis_client\n      .xadd(&self.stream_name, \"*\", &[(\"task\", task.to_string())])\n      .await;\n    result.unwrap();\n  }\n}\n\nstruct MockNotifier {\n  tx: tokio::sync::broadcast::Sender<ImportProgress>,\n}\n\nimpl MockNotifier {\n  fn new() -> Self {\n    let (tx, _) = tokio::sync::broadcast::channel(100);\n    Self { tx }\n  }\n  fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ImportProgress> {\n    self.tx.subscribe()\n  }\n}\n\n#[async_trait]\nimpl ImportNotifier for MockNotifier {\n  async fn notify_progress(&self, progress: ImportProgress) {\n    println!(\"notify_progress: {:?}\", progress);\n    self.tx.send(progress).unwrap();\n  }\n}\n\nstruct MockS3Client;\n\n#[async_trait]\nimpl S3Client for MockS3Client {\n  async fn get_blob_stream(&self, _object_key: &str) -> Result<S3StreamResponse, WorkerError> {\n    todo!()\n  }\n\n  async fn put_blob(\n    &self,\n    _object_key: &str,\n    _content: ByteStream,\n    _content_type: Option<&str>,\n  ) -> std::result::Result<(), WorkerError> {\n    todo!()\n  }\n\n  async fn delete_blob(&self, _object_key: &str) -> Result<(), WorkerError> {\n    Ok(())\n  }\n\n  async fn is_blob_exist(&self, _object_key: &str) -> Result<bool, WorkerError> {\n    Ok(false)\n  }\n\n  async fn get_blob_meta(&self, _object_key: &str) -> Result<BlobMeta, WorkerError> {\n    todo!()\n  }\n}\n\npub fn setup_log() {\n  static START: Once = Once::new();\n  START.call_once(|| {\n    let level = std::env::var(\"RUST_LOG\").unwrap_or(\"trace\".to_string());\n    let mut filters = vec![];\n    filters.push(format!(\"appflowy_worker={}\", level));\n    std::env::set_var(\"RUST_LOG\", filters.join(\",\"));\n\n    let subscriber = Subscriber::builder()\n      .with_ansi(true)\n      .with_env_filter(EnvFilter::from_default_env())\n      .finish();\n    subscriber.try_init().unwrap();\n  });\n}\n"
  },
  {
    "path": "services/appflowy-worker/tests/main.rs",
    "content": "mod import_test;\n"
  },
  {
    "path": "src/api/access_request.rs",
    "content": "use actix_web::{\n  web::{self, Data, Json},\n  Result, Scope,\n};\n\nuse database_entity::dto::{\n  AccessRequestMinimal, ApproveAccessRequestParams, CreateAccessRequestParams,\n};\nuse shared_entity::{\n  dto::access_request_dto::AccessRequest,\n  response::{AppResponse, JsonAppResponse},\n};\nuse uuid::Uuid;\n\nuse crate::{\n  biz::{\n    access_request::ops::{\n      approve_or_reject_access_request, create_access_request, get_access_request,\n    },\n    authentication::jwt::UserUuid,\n  },\n  state::AppState,\n};\n\npub fn access_request_scope() -> Scope {\n  web::scope(\"/api/access-request\")\n    .service(web::resource(\"\").route(web::post().to(post_access_request_handler)))\n    .service(web::resource(\"/{request_id}\").route(web::get().to(get_access_request_handler)))\n    .service(\n      web::resource(\"/{request_id}/approve\")\n        .route(web::post().to(post_approve_access_request_handler)),\n    )\n}\n\nasync fn get_access_request_handler(\n  uuid: UserUuid,\n  access_request_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<AccessRequest>> {\n  let access_request_id = access_request_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  let access_request =\n    get_access_request(&state.pg_pool, &state.ws_server, access_request_id, uid).await?;\n  Ok(Json(AppResponse::Ok().with_data(access_request)))\n}\n\nasync fn post_access_request_handler(\n  uuid: UserUuid,\n  create_access_request_params: Json<CreateAccessRequestParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<AccessRequestMinimal>> {\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  let workspace_id = create_access_request_params.workspace_id;\n  let view_id = create_access_request_params.view_id;\n  let request_id = create_access_request(\n    &state.pg_pool,\n    state.mailer.clone(),\n    &state.config.appflowy_web_url,\n    workspace_id,\n    view_id,\n    uid,\n  )\n  .await?;\n  let access_request = AccessRequestMinimal {\n    request_id,\n    workspace_id,\n    requester_id: *uuid,\n    view_id,\n  };\n  Ok(Json(AppResponse::Ok().with_data(access_request)))\n}\n\nasync fn post_approve_access_request_handler(\n  uuid: UserUuid,\n  access_request_id: web::Path<Uuid>,\n  approve_access_request_params: Json<ApproveAccessRequestParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  let access_request_id = access_request_id.into_inner();\n  let is_approved = approve_access_request_params.is_approved;\n  approve_or_reject_access_request(\n    &state.pg_pool,\n    state.workspace_access_control.clone(),\n    state.mailer.clone(),\n    &state.config.appflowy_web_url,\n    access_request_id,\n    uid,\n    is_approved,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n"
  },
  {
    "path": "src/api/ai.rs",
    "content": "use crate::api::util::ai_model_from_header;\nuse crate::state::AppState;\n\nuse actix_web::web::{Data, Json};\nuse actix_web::{web, HttpRequest, HttpResponse, Scope};\nuse app_error::AppError;\nuse appflowy_ai_client::dto::{\n  CalculateSimilarityParams, LocalAIConfig, ModelList, SimilarityResponse, TranslateRowParams,\n  TranslateRowResponse,\n};\n\nuse futures_util::{stream, TryStreamExt};\n\nuse serde::Deserialize;\nuse shared_entity::dto::ai_dto::{\n  CompleteTextParams, SummarizeRowData, SummarizeRowParams, SummarizeRowResponse,\n};\nuse shared_entity::response::AppResponse;\n\nuse tracing::{error, instrument, trace};\n\npub fn ai_completion_scope() -> Scope {\n  web::scope(\"/api/ai/{workspace_id}\")\n    .service(web::resource(\"/complete/stream\").route(web::post().to(stream_complete_text_handler)))\n    .service(web::resource(\"/v2/complete/stream\").route(web::post().to(stream_complete_v2_handler)))\n    .service(web::resource(\"/summarize_row\").route(web::post().to(summarize_row_handler)))\n    .service(web::resource(\"/translate_row\").route(web::post().to(translate_row_handler)))\n    .service(web::resource(\"/local/config\").route(web::get().to(local_ai_config_handler)))\n    .service(\n      web::resource(\"/calculate_similarity\").route(web::post().to(calculate_similarity_handler)),\n    )\n    .service(web::resource(\"/model/list\").route(web::get().to(model_list_handler)))\n}\n\nasync fn stream_complete_text_handler(\n  state: Data<AppState>,\n  payload: Json<CompleteTextParams>,\n  req: HttpRequest,\n) -> actix_web::Result<HttpResponse> {\n  let ai_model = ai_model_from_header(&req);\n  let params = payload.into_inner();\n  state.metrics.ai_metrics.record_total_completion_count(1);\n\n  if let Some(prompt_id) = params\n    .metadata\n    .as_ref()\n    .and_then(|metadata| metadata.prompt_id.as_ref())\n  {\n    state\n      .metrics\n      .ai_metrics\n      .record_prompt_usage_count(prompt_id, 1);\n  }\n\n  match state\n    .ai_client\n    .stream_completion_text(params, ai_model)\n    .await\n  {\n    Ok(stream) => Ok(\n      HttpResponse::Ok()\n        .content_type(\"text/event-stream\")\n        .streaming(stream.map_err(AppError::from)),\n    ),\n    Err(err) => Ok(\n      HttpResponse::Ok()\n        .content_type(\"text/event-stream\")\n        .streaming(stream::once(async move {\n          Err(AppError::AIServiceUnavailable(err.to_string()))\n        })),\n    ),\n  }\n}\n\nasync fn stream_complete_v2_handler(\n  state: Data<AppState>,\n  payload: Json<CompleteTextParams>,\n  req: HttpRequest,\n) -> actix_web::Result<HttpResponse> {\n  let ai_model = ai_model_from_header(&req);\n  let params = payload.into_inner();\n  state.metrics.ai_metrics.record_total_completion_count(1);\n\n  match state.ai_client.stream_completion_v2(params, ai_model).await {\n    Ok(stream) => Ok(\n      HttpResponse::Ok()\n        .content_type(\"text/event-stream\")\n        .streaming(stream.map_err(AppError::from)),\n    ),\n    Err(err) => Ok(\n      HttpResponse::Ok()\n        .content_type(\"text/event-stream\")\n        .streaming(stream::once(async move {\n          Err(AppError::AIServiceUnavailable(err.to_string()))\n        })),\n    ),\n  }\n}\n#[instrument(level = \"debug\", skip(state, payload), err)]\nasync fn summarize_row_handler(\n  state: Data<AppState>,\n  payload: Json<SummarizeRowParams>,\n  req: HttpRequest,\n) -> actix_web::Result<Json<AppResponse<SummarizeRowResponse>>> {\n  let params = payload.into_inner();\n  match params.data {\n    SummarizeRowData::Identity { .. } => {\n      return Err(AppError::InvalidRequest(\"Identity data is not supported\".to_string()).into());\n    },\n    SummarizeRowData::Content(content) => {\n      if content.is_empty() {\n        return Ok(\n          AppResponse::Ok()\n            .with_data(SummarizeRowResponse {\n              text: \"No content\".to_string(),\n            })\n            .into(),\n        );\n      }\n\n      state.metrics.ai_metrics.record_total_summary_row_count(1);\n      let ai_model = ai_model_from_header(&req);\n      let result = state.ai_client.summarize_row(&content, ai_model).await;\n      let resp = match result {\n        Ok(resp) => SummarizeRowResponse { text: resp.text },\n        Err(err) => {\n          error!(\"Failed to summarize row: {:?}\", err);\n          SummarizeRowResponse {\n            text: \"No content\".to_string(),\n          }\n        },\n      };\n\n      Ok(AppResponse::Ok().with_data(resp).into())\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip(state, payload), err)]\nasync fn translate_row_handler(\n  state: web::Data<AppState>,\n  payload: web::Json<TranslateRowParams>,\n  req: HttpRequest,\n) -> actix_web::Result<Json<AppResponse<TranslateRowResponse>>> {\n  let params = payload.into_inner();\n  let ai_model = ai_model_from_header(&req);\n  state.metrics.ai_metrics.record_total_translate_row_count(1);\n  match state.ai_client.translate_row(params.data, ai_model).await {\n    Ok(resp) => Ok(AppResponse::Ok().with_data(resp).into()),\n    Err(err) => {\n      error!(\"Failed to translate row: {:?}\", err);\n      Ok(\n        AppResponse::Ok()\n          .with_data(TranslateRowResponse::default())\n          .into(),\n      )\n    },\n  }\n}\n\n#[derive(Deserialize, Debug)]\nstruct ConfigQuery {\n  platform: String,\n  app_version: Option<String>,\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn local_ai_config_handler(\n  state: web::Data<AppState>,\n  query: web::Query<ConfigQuery>,\n) -> actix_web::Result<Json<AppResponse<LocalAIConfig>>> {\n  let query = query.into_inner();\n  trace!(\"query ai configuration: {:?}\", query);\n  let platform = match query.platform.as_str() {\n    \"macos\" => \"macos\",\n    \"linux\" => \"ubuntu\",\n    \"ubuntu\" => \"ubuntu\",\n    \"windows\" => \"windows\",\n    _ => {\n      return Err(AppError::InvalidRequest(\"Invalid platform\".to_string()).into());\n    },\n  };\n\n  let config = state\n    .ai_client\n    .get_local_ai_config(platform, query.app_version)\n    .await\n    .map_err(|err| AppError::AIServiceUnavailable(err.to_string()))?;\n  Ok(AppResponse::Ok().with_data(config).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn calculate_similarity_handler(\n  state: web::Data<AppState>,\n  payload: web::Json<CalculateSimilarityParams>,\n) -> actix_web::Result<Json<AppResponse<SimilarityResponse>>> {\n  let params = payload.into_inner();\n\n  let response = state\n    .ai_client\n    .calculate_similarity(params)\n    .await\n    .map_err(|err| AppError::AIServiceUnavailable(err.to_string()))?;\n  Ok(AppResponse::Ok().with_data(response).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn model_list_handler(\n  state: web::Data<AppState>,\n) -> actix_web::Result<Json<AppResponse<ModelList>>> {\n  let model_list = state\n    .ai_client\n    .get_model_list()\n    .await\n    .map_err(|err| AppError::AIServiceUnavailable(err.to_string()))?;\n  Ok(AppResponse::Ok().with_data(model_list).into())\n}\n"
  },
  {
    "path": "src/api/chat.rs",
    "content": "use crate::biz::authentication::jwt::UserUuid;\nuse crate::biz::chat::ops::{\n  create_chat, create_chat_message, delete_chat, generate_chat_message_answer,\n  get_chat_messages_with_author_uuid, get_question_message, update_chat_message,\n};\nuse crate::state::AppState;\nuse actix_web::web::{Data, Json};\nuse actix_web::{web, HttpRequest, HttpResponse, Scope};\nuse serde::Deserialize;\n\nuse crate::api::util::ai_model_from_header;\nuse app_error::AppError;\nuse appflowy_ai_client::dto::{\n  ChatQuestion, ChatQuestionQuery, CreateChatContext, MessageData, QuestionMetadata,\n  RepeatedRelatedQuestion,\n};\n\nuse bytes::Bytes;\nuse database::chat;\nuse futures::Stream;\nuse futures_util::stream;\nuse futures_util::{FutureExt, TryStreamExt};\nuse pin_project::pin_project;\nuse shared_entity::dto::chat_dto::{\n  ChatAuthor, ChatMessage, ChatMessageWithAuthorUuid, ChatSettings, CreateAnswerMessageParams,\n  CreateChatMessageParams, CreateChatParams, GetChatMessageParams, MessageCursor,\n  RepeatedChatMessageWithAuthorUuid, UpdateChatMessageContentParams, UpdateChatParams,\n};\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse std::collections::HashMap;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\nuse tokio::sync::oneshot;\nuse tokio::task;\nuse tracing::{instrument, trace};\nuse uuid::Uuid;\nuse validator::Validate;\npub fn chat_scope() -> Scope {\n  web::scope(\"/api/chat/{workspace_id}\")\n      // Chat CRUD\n      .service(\n        web::resource(\"\")\n            .route(web::post().to(create_chat_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}\")\n            .route(web::delete().to(delete_chat_handler))\n            // Deprecated, use /message instead\n            .route(web::get().to(get_chat_message_handler))\n      )\n\n      // Settings\n      .service(\n        web::resource(\"/{chat_id}/settings\")\n            .route(web::get().to(get_chat_settings_handler))\n            .route(web::post().to(update_chat_settings_handler))\n      )\n\n      // Message management\n      .service(\n        web::resource(\"/{chat_id}/message\")\n            .route(web::put().to(update_question_handler))\n            .route(web::get().to(get_chat_message_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}/message/question\")\n            .route(web::post().to(create_question_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}/message/answer\")\n            .route(web::post().to(create_answer_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}/message/find_question\")\n            .route(web::get().to(get_chat_question_message_handler))\n      )\n\n      // AI response generation\n      .service(\n        web::resource(\"/{chat_id}/{message_id}/answer\")\n            .route(web::get().to(answer_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}/{message_id}/answer/stream\")\n            .route(web::get().to(answer_stream_handler)) // Deprecated\n      )\n      .service(\n        web::resource(\"/{chat_id}/{message_id}/v2/answer/stream\")\n            .route(web::get().to(answer_stream_v2_handler))  // Deprecated since 0.9.2\n      )\n      .service(\n        web::resource(\"/{chat_id}/answer/stream\")\n            .route(web::post().to(answer_stream_v3_handler))\n      )\n\n      // Additional functionality\n      .service(\n        web::resource(\"/{chat_id}/{message_id}/related_question\")\n            .route(web::get().to(get_related_message_handler))\n      )\n      .service(\n        web::resource(\"/{chat_id}/context/text\")\n            .route(web::post().to(create_chat_context_handler))\n      )\n}\nasync fn create_chat_handler(\n  path: web::Path<Uuid>,\n  state: Data<AppState>,\n  payload: Json<CreateChatParams>,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let workspace_id = path.into_inner();\n  let params = payload.into_inner();\n  create_chat(&state.pg_pool, params, &workspace_id).await?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn delete_chat_handler(\n  path: web::Path<(String, String)>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let (_workspace_id, chat_id) = path.into_inner();\n  delete_chat(&state.pg_pool, &chat_id).await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn create_chat_context_handler(\n  state: Data<AppState>,\n  payload: Json<CreateChatContext>,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let params = payload.into_inner();\n  state\n    .ai_client\n    .create_chat_text_context(params)\n    .await\n    .map_err(AppError::from)?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn update_question_handler(\n  path: web::Path<(String, String)>,\n  state: Data<AppState>,\n  payload: Json<UpdateChatMessageContentParams>,\n  req: HttpRequest,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let (workspace_id, _chat_id) = path.into_inner();\n  let params = payload.into_inner();\n  let ai_model = ai_model_from_header(&req);\n  update_chat_message(\n    workspace_id,\n    &state.pg_pool,\n    params,\n    state.ai_client.clone(),\n    ai_model,\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn get_related_message_handler(\n  path: web::Path<(String, String, i64)>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> actix_web::Result<JsonAppResponse<RepeatedRelatedQuestion>> {\n  let (_workspace_id, chat_id, message_id) = path.into_inner();\n  let ai_model = ai_model_from_header(&req);\n  let resp = state\n    .ai_client\n    .get_related_question(&chat_id, &message_id, ai_model)\n    .await\n    .map_err(|err| AppError::Internal(err.into()))?;\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn create_question_handler(\n  state: Data<AppState>,\n  path: web::Path<(String, String)>,\n  payload: Json<CreateChatMessageParams>,\n  uuid: UserUuid,\n) -> actix_web::Result<JsonAppResponse<ChatMessageWithAuthorUuid>> {\n  let (_workspace_id, chat_id) = path.into_inner();\n  let params = payload.into_inner();\n\n  if let Some(ref prompt_id) = params.prompt_id {\n    state\n      .metrics\n      .ai_metrics\n      .record_prompt_usage_count(prompt_id, 1);\n  }\n\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  let resp = create_chat_message(&state.pg_pool, uid, *uuid, chat_id, params).await?;\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\nasync fn create_answer_handler(\n  path: web::Path<(String, String)>,\n  payload: Json<CreateAnswerMessageParams>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<ChatMessage>> {\n  let payload = payload.into_inner();\n  payload.validate().map_err(AppError::from)?;\n\n  let (_workspace_id, chat_id) = path.into_inner();\n  let message = database::chat::chat_ops::insert_answer_message(\n    &state.pg_pool,\n    ChatAuthor::ai(),\n    &chat_id,\n    payload.content,\n    payload.metadata,\n    payload.question_message_id,\n  )\n  .await?;\n\n  Ok(AppResponse::Ok().with_data(message).into())\n}\nasync fn answer_handler(\n  path: web::Path<(String, String, i64)>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> actix_web::Result<JsonAppResponse<ChatMessage>> {\n  let (workspace_id, chat_id, message_id) = path.into_inner();\n  let ai_model = ai_model_from_header(&req);\n  let message = generate_chat_message_answer(\n    workspace_id,\n    &state.pg_pool,\n    state.ai_client.clone(),\n    message_id,\n    &chat_id,\n    ai_model,\n  )\n  .await?;\n  Ok(AppResponse::Ok().with_data(message).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn answer_stream_handler(\n  path: web::Path<(String, String, i64)>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> actix_web::Result<HttpResponse> {\n  let (workspace_id, chat_id, question_id) = path.into_inner();\n  let (content, metadata) =\n    chat::chat_ops::select_chat_message_content(&state.pg_pool, question_id).await?;\n  let rag_ids = chat::chat_ops::select_chat_rag_ids(&state.pg_pool, &chat_id).await?;\n  let ai_model = ai_model_from_header(&req);\n  state.metrics.ai_metrics.record_total_stream_count(1);\n  match state\n    .ai_client\n    .stream_question(\n      workspace_id,\n      &chat_id,\n      &content,\n      Some(metadata),\n      rag_ids,\n      ai_model,\n    )\n    .await\n  {\n    Ok(answer_stream) => {\n      let new_answer_stream = answer_stream.map_err(AppError::from);\n      Ok(\n        HttpResponse::Ok()\n          .content_type(\"text/event-stream\")\n          .streaming(new_answer_stream),\n      )\n    },\n    Err(err) => {\n      state.metrics.ai_metrics.record_failed_stream_count(1);\n      Ok(\n        HttpResponse::Ok()\n          .content_type(\"text/event-stream\")\n          .streaming(stream::once(async move {\n            Err(AppError::AIServiceUnavailable(err.to_string()))\n          })),\n      )\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn answer_stream_v2_handler(\n  path: web::Path<(String, String, i64)>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> actix_web::Result<HttpResponse> {\n  let (workspace_id, chat_id, question_id) = path.into_inner();\n  let (content, metadata) =\n    chat::chat_ops::select_chat_message_content(&state.pg_pool, question_id).await?;\n  let rag_ids = chat::chat_ops::select_chat_rag_ids(&state.pg_pool, &chat_id).await?;\n  let ai_model = ai_model_from_header(&req);\n\n  state.metrics.ai_metrics.record_total_stream_count(1);\n  trace!(\n    \"[Chat] stream answer for chat: {}, question: {}, rag_ids: {:?}\",\n    chat_id,\n    content,\n    rag_ids\n  );\n  match state\n    .ai_client\n    .stream_question_v2(\n      workspace_id,\n      &chat_id,\n      question_id,\n      &content,\n      Some(metadata),\n      rag_ids,\n      ai_model,\n    )\n    .await\n  {\n    Ok(answer_stream) => {\n      let new_answer_stream = answer_stream.map_err(AppError::from);\n      Ok(\n        HttpResponse::Ok()\n          .content_type(\"text/event-stream\")\n          .streaming(new_answer_stream),\n      )\n    },\n    Err(err) => {\n      trace!(\"[Chat] stream answer failed: {}\", err);\n      state.metrics.ai_metrics.record_failed_stream_count(1);\n      Ok(\n        HttpResponse::ServiceUnavailable()\n          .content_type(\"text/event-stream\")\n          .streaming(stream::once(async move {\n            Err(AppError::AIServiceUnavailable(err.to_string()))\n          })),\n      )\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn answer_stream_v3_handler(\n  path: web::Path<(String, String)>,\n  payload: Json<ChatQuestionQuery>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> actix_web::Result<HttpResponse> {\n  let (workspace_id, _) = path.into_inner();\n  let payload = payload.into_inner();\n  let (content, metadata) =\n    chat::chat_ops::select_chat_message_content(&state.pg_pool, payload.question_id).await?;\n  let rag_ids = chat::chat_ops::select_chat_rag_ids(&state.pg_pool, &payload.chat_id).await?;\n  let ai_model = ai_model_from_header(&req);\n  state.metrics.ai_metrics.record_total_stream_count(1);\n  if payload.format.output_content.is_image() {\n    state.metrics.ai_metrics.record_stream_image_count(1);\n  }\n\n  let question = ChatQuestion {\n    chat_id: payload.chat_id,\n    data: MessageData {\n      content: content.to_string(),\n      metadata: Some(metadata),\n      message_id: Some(payload.question_id.to_string()),\n    },\n    format: payload.format,\n    metadata: QuestionMetadata {\n      workspace_id,\n      rag_ids,\n    },\n  };\n\n  trace!(\"[Chat] stream v3 {:?}\", question);\n  match state\n    .ai_client\n    .stream_question_v3(ai_model, question, Some(60))\n    .await\n  {\n    Ok(answer_stream) => {\n      let new_answer_stream = answer_stream.map_err(AppError::from);\n      Ok(\n        HttpResponse::Ok()\n          .content_type(\"text/event-stream\")\n          .streaming(new_answer_stream),\n      )\n    },\n    Err(err) => {\n      state.metrics.ai_metrics.record_failed_stream_count(1);\n      Ok(\n        HttpResponse::ServiceUnavailable()\n          .content_type(\"text/event-stream\")\n          .streaming(stream::once(async move {\n            Err(AppError::AIServiceUnavailable(err.to_string()))\n          })),\n      )\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn get_chat_message_handler(\n  path: web::Path<(String, String)>,\n  query: web::Query<HashMap<String, String>>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<RepeatedChatMessageWithAuthorUuid>> {\n  let mut params = GetChatMessageParams {\n    cursor: MessageCursor::Offset(0),\n    limit: query\n      .get(\"limit\")\n      .and_then(|s| s.parse::<u64>().ok())\n      .unwrap_or(10),\n  };\n  if let Some(value) = query.get(\"offset\").and_then(|s| s.parse::<u64>().ok()) {\n    params.cursor = MessageCursor::Offset(value);\n  } else if let Some(value) = query.get(\"after\").and_then(|s| s.parse::<i64>().ok()) {\n    params.cursor = MessageCursor::AfterMessageId(value);\n  } else if let Some(value) = query.get(\"before\").and_then(|s| s.parse::<i64>().ok()) {\n    params.cursor = MessageCursor::BeforeMessageId(value);\n  } else {\n    params.cursor = MessageCursor::NextBack;\n  }\n\n  trace!(\"get chat messages: {:?}\", params);\n  let (_workspace_id, chat_id) = path.into_inner();\n  let messages = get_chat_messages_with_author_uuid(&state.pg_pool, params, &chat_id).await?;\n  Ok(AppResponse::Ok().with_data(messages).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn get_chat_question_message_handler(\n  path: web::Path<(String, String)>,\n  query: web::Query<FindQuestionParams>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<Option<ChatMessage>>> {\n  let (_workspace_id, chat_id) = path.into_inner();\n  let message = get_question_message(&state.pg_pool, &chat_id, query.0.answer_message_id).await?;\n  Ok(AppResponse::Ok().with_data(message).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn get_chat_settings_handler(\n  path: web::Path<(String, String)>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<ChatSettings>> {\n  let (_, chat_id) = path.into_inner();\n  let chat_id_uuid = Uuid::parse_str(&chat_id).map_err(AppError::from)?;\n  let settings = chat::chat_ops::select_chat_settings(&state.pg_pool, &chat_id_uuid).await?;\n  Ok(AppResponse::Ok().with_data(settings).into())\n}\n\nasync fn update_chat_settings_handler(\n  path: web::Path<(String, String)>,\n  state: Data<AppState>,\n  payload: Json<UpdateChatParams>,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let (_workspace_id, chat_id) = path.into_inner();\n  let chat_id_uuid = Uuid::parse_str(&chat_id).map_err(AppError::from)?;\n  chat::chat_ops::update_chat_settings(&state.pg_pool, &chat_id_uuid, payload.into_inner()).await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[pin_project]\npub struct FinalAnswerStream<S, F> {\n  #[pin]\n  stream: S,\n  buffer: Vec<u8>,\n  action: Option<F>,\n}\n\nimpl<S, F> FinalAnswerStream<S, F> {\n  pub fn new(stream: S, action: F) -> Self {\n    FinalAnswerStream {\n      stream,\n      buffer: Vec::new(),\n      action: Some(action),\n    }\n  }\n}\n\nimpl<S, F> Stream for FinalAnswerStream<S, F>\nwhere\n  S: Stream<Item = Result<String, AppError>>,\n  F: FnOnce(Vec<u8>),\n{\n  type Item = Result<Bytes, AppError>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let this = self.project();\n\n    match this.stream.poll_next(cx) {\n      Poll::Ready(Some(Ok(item))) => {\n        let bytes = item.into_bytes();\n        this.buffer.extend_from_slice(&bytes);\n        Poll::Ready(Some(Ok(Bytes::from(bytes))))\n      },\n      Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),\n      Poll::Ready(None) => {\n        if let Some(action) = this.action.take() {\n          action(std::mem::take(this.buffer));\n        }\n        Poll::Ready(None)\n      },\n      Poll::Pending => Poll::Pending,\n    }\n  }\n}\n\n#[allow(dead_code)]\n#[pin_project]\npub struct CollectingStream<S, F> {\n  #[pin]\n  stream: S,\n  buffer: Vec<u8>,\n  action: Option<F>,\n  state: CollectingStreamType,\n  result_receiver: Option<oneshot::Receiver<Result<Bytes, AppError>>>,\n}\n\nenum CollectingStreamType {\n  AnswerString,\n  AnswerMessage,\n}\n\nimpl<S, F> CollectingStream<S, F> {\n  pub fn new(stream: S, action: F) -> Self {\n    CollectingStream {\n      stream,\n      buffer: Vec::new(),\n      action: Some(action),\n      state: CollectingStreamType::AnswerString,\n      result_receiver: None,\n    }\n  }\n}\n\nimpl<S, F> Stream for CollectingStream<S, F>\nwhere\n  S: Stream<Item = Result<Bytes, AppError>>,\n  F: FnOnce(Vec<u8>) -> task::JoinHandle<Result<Bytes, AppError>> + Send + 'static,\n{\n  type Item = Result<Bytes, AppError>;\n\n  fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    let mut this = self.project();\n    match this.state {\n      CollectingStreamType::AnswerString => {\n        match this.stream.as_mut().poll_next(cx) {\n          Poll::Ready(Some(Ok(bytes))) => {\n            this.buffer.extend_from_slice(&bytes);\n            Poll::Ready(Some(Ok(bytes)))\n          },\n          Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),\n          Poll::Ready(None) => {\n            if let Some(action) = this.action.take() {\n              let buffer = std::mem::take(this.buffer);\n              let (sender, receiver) = oneshot::channel();\n              *this.result_receiver = Some(receiver);\n              *this.state = CollectingStreamType::AnswerMessage;\n\n              // Spawn the async task to handle the buffer and send the result\n              tokio::spawn(async move {\n                let result = action(buffer).await;\n                sender.send(result.unwrap()).unwrap();\n              });\n\n              Poll::Ready(Some(Ok(Bytes::from(\"\"))))\n            } else {\n              // If action is None, it means the stream is finished.\n              Poll::Ready(None)\n            }\n          },\n          Poll::Pending => Poll::Pending,\n        }\n      },\n      CollectingStreamType::AnswerMessage => {\n        if let Some(receiver) = this.result_receiver.as_mut() {\n          match receiver.poll_unpin(cx) {\n            Poll::Ready(Ok(_)) => {\n              this.result_receiver.take();\n              Poll::Ready(None)\n            },\n            Poll::Ready(Err(_)) => Poll::Ready(None),\n            Poll::Pending => Poll::Pending,\n          }\n        } else {\n          Poll::Ready(None)\n        }\n      },\n    }\n  }\n}\n\n#[derive(Debug, Deserialize)]\nstruct FindQuestionParams {\n  answer_message_id: i64,\n}\n"
  },
  {
    "path": "src/api/data_import.rs",
    "content": "use crate::biz::authentication::jwt::UserUuid;\nuse crate::state::AppState;\nuse actix_multipart::Multipart;\nuse actix_web::web::{Data, Json};\nuse actix_web::{web, HttpRequest, Scope};\nuse anyhow::anyhow;\nuse app_error::AppError;\n\nuse aws_sdk_s3::primitives::ByteStream;\nuse database::file::BucketClient;\n\nuse crate::biz::workspace::ops::{create_empty_workspace, create_upload_task, num_pending_task};\nuse base64::engine::general_purpose::STANDARD;\nuse base64::Engine;\nuse database::user::select_name_and_email_from_uuid;\nuse database::workspace::select_import_task_by_state;\nuse database_entity::dto::{CreateImportTask, CreateImportTaskResponse};\nuse futures_util::StreamExt;\nuse infra::env_util::get_env_var;\nuse serde_json::json;\nuse shared_entity::dto::import_dto::{ImportTaskDetail, UserImportTask};\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse std::env::temp_dir;\nuse std::path::PathBuf;\nuse tokio::fs::File;\nuse tokio::io::AsyncWriteExt;\nuse tracing::{error, info, instrument, trace};\nuse uuid::Uuid;\nuse validator::Validate;\n\npub fn data_import_scope() -> Scope {\n  web::scope(\"/api/import\")\n    .service(\n      web::resource(\"\")\n        .route(web::post().to(import_data_handler))\n        .route(web::get().to(get_import_detail_handler)),\n    )\n    .service(web::resource(\"/create\").route(web::post().to(create_import_handler)))\n}\n\n#[instrument(level = \"debug\", skip_all)]\nasync fn create_import_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  payload: Json<CreateImportTask>,\n  req: HttpRequest,\n) -> actix_web::Result<JsonAppResponse<CreateImportTaskResponse>> {\n  let params = payload.into_inner();\n  params.validate().map_err(AppError::from)?;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  check_maximum_task(&state, uid).await?;\n  let s3_key = format!(\"import_presigned_url_{}\", Uuid::new_v4());\n\n  // Generate presigned url with 10 minutes expiration\n  let presigned_url = state\n    .bucket_client\n    .gen_presigned_url(&s3_key, params.content_length, 600)\n    .await?;\n  trace!(\"[Import] Presigned url: {}\", presigned_url);\n\n  let (user_name, user_email) = select_name_and_email_from_uuid(&state.pg_pool, &user_uuid).await?;\n  let host = get_host_from_request(&req);\n  let workspace = create_empty_workspace(\n    &state.pg_pool,\n    state.workspace_access_control.clone(),\n    &state.collab_storage,\n    &state.metrics.collab_metrics,\n    &user_uuid,\n    uid,\n    &params.workspace_name,\n  )\n  .await?;\n\n  let workspace_id = workspace.workspace_id.to_string();\n  info!(\n    \"User:{} import new workspace:{}, name:{}\",\n    uid, workspace_id, params.workspace_name,\n  );\n  let timestamp = chrono::Utc::now().timestamp();\n  let task_id = Uuid::new_v4();\n  let task = json!({\n      \"notion\": {\n         \"uid\": uid,\n         \"user_name\": user_name,\n         \"user_email\": user_email,\n         \"task_id\": task_id.to_string(),\n         \"workspace_id\": workspace_id,\n         \"file_size\":params.content_length,\n         \"created_at\": timestamp,\n         \"s3_key\": s3_key,\n         \"host\": host,\n         \"workspace_name\": &params.workspace_name,\n      }\n  });\n\n  let data = CreateImportTaskResponse {\n    task_id: task_id.to_string(),\n    presigned_url: presigned_url.clone(),\n  };\n\n  create_upload_task(\n    uid,\n    task_id,\n    task,\n    &host,\n    &workspace_id,\n    0,\n    Some(presigned_url),\n    &state.redis_connection_manager,\n    &state.pg_pool,\n  )\n  .await?;\n\n  Ok(AppResponse::Ok().with_data(data).into())\n}\n\nasync fn get_import_detail_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<UserImportTask>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let tasks = select_import_task_by_state(uid, &state.pg_pool, None)\n    .await\n    .map(|tasks| {\n      tasks\n        .into_iter()\n        .map(|task| ImportTaskDetail {\n          task_id: task.task_id.to_string(),\n          file_size: task.file_size as u64,\n          created_at: task.created_at.timestamp(),\n          status: task.status,\n        })\n        .collect::<Vec<_>>()\n    })?;\n\n  Ok(\n    AppResponse::Ok()\n      .with_data(UserImportTask {\n        tasks,\n        has_more: false,\n      })\n      .into(),\n  )\n}\n\nasync fn import_data_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  mut payload: Multipart,\n  req: HttpRequest,\n) -> actix_web::Result<JsonAppResponse<()>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  check_maximum_task(&state, uid).await?;\n\n  let (user_name, user_email) = select_name_and_email_from_uuid(&state.pg_pool, &user_uuid).await?;\n  let host = get_host_from_request(&req);\n  let content_length = req\n    .headers()\n    .get(\"X-Content-Length\")\n    .and_then(|h| h.to_str().ok())\n    .and_then(|s| s.parse::<usize>().ok())\n    .unwrap_or(0);\n\n  let md5_base64 = req\n    .headers()\n    .get(\"X-Content-MD5\")\n    .and_then(|h| h.to_str().ok())\n    .unwrap_or(\"\");\n\n  let file_path = temp_dir().join(format!(\"import_data_{}.zip\", Uuid::new_v4()));\n  let file = write_multiple_part(&mut payload, file_path).await?;\n\n  trace!(\n    \"[Import] content length: {}, content md5: {}\",\n    content_length,\n    md5_base64\n  );\n  if file.md5_base64 != md5_base64 {\n    trace!(\n      \"Import file fail. The Content-MD5:{} doesn't match file md5:{}\",\n      md5_base64,\n      file.md5_base64\n    );\n\n    return Err(\n      AppError::InvalidRequest(format!(\n        \"Content-MD5:{} doesn't match file md5:{}\",\n        md5_base64, file.md5_base64\n      ))\n      .into(),\n    );\n  }\n\n  if content_length != file.size {\n    trace!(\n      \"Import file fail. The Content-Length:{} doesn't match file size:{}\",\n      content_length,\n      file.size\n    );\n\n    return Err(\n      AppError::InvalidRequest(format!(\n        \"Content-Length:{} doesn't match file size:{}\",\n        content_length, file.size\n      ))\n      .into(),\n    );\n  }\n\n  let workspace = create_empty_workspace(\n    &state.pg_pool,\n    state.workspace_access_control.clone(),\n    &state.collab_storage,\n    &state.metrics.collab_metrics,\n    &user_uuid,\n    uid,\n    &file.name,\n  )\n  .await?;\n\n  let workspace_id = workspace.workspace_id.to_string();\n  info!(\n    \"User:{} import data:{} to new workspace:{}, name:{}\",\n    uid, file.size, workspace_id, file.name,\n  );\n\n  upload_file_with_retry(&state, &workspace_id, &file.file_path).await?;\n\n  // This task will be deserialized into ImportTask\n  let task_id = Uuid::new_v4();\n  let task = json!({\n      \"notion\": {\n         \"uid\": uid,\n         \"user_name\": user_name,\n         \"user_email\": user_email,\n         \"task_id\": task_id.to_string(),\n         \"workspace_id\": workspace_id,\n         \"s3_key\": workspace_id,\n         \"host\": host,\n         \"workspace_name\": &file.name,\n         \"md5_base64\": md5_base64,\n      }\n  });\n\n  create_upload_task(\n    uid,\n    task_id,\n    task,\n    &host,\n    &workspace_id,\n    file.size,\n    None,\n    &state.redis_connection_manager,\n    &state.pg_pool,\n  )\n  .await?;\n\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn upload_file_with_retry(\n  state: &AppState,\n  workspace_id: &str,\n  file_path: &PathBuf,\n) -> Result<(), AppError> {\n  let mut attempt = 0;\n  let max_retries = 3;\n\n  while attempt <= max_retries {\n    let stream = ByteStream::from_path(file_path).await.map_err(|e| {\n      AppError::Internal(anyhow!(\"Failed to create ByteStream from file path: {}\", e))\n    })?;\n    let result = state\n      .bucket_client\n      .put_blob_with_content_type(workspace_id, stream, \"application/zip\")\n      .await;\n\n    match result {\n      Ok(_) => return Ok(()),\n      Err(AppError::ServiceTemporaryUnavailable(_)) if attempt < max_retries => {\n        attempt += 1;\n        tokio::time::sleep(std::time::Duration::from_secs(5)).await;\n      },\n      Err(err) => return Err(err),\n    }\n  }\n\n  Err(AppError::ServiceTemporaryUnavailable(\n    \"Failed to upload file to S3\".to_string(),\n  ))\n}\n\nasync fn check_maximum_task(state: &Data<AppState>, uid: i64) -> Result<(), AppError> {\n  let count = num_pending_task(uid, &state.pg_pool).await?;\n  let maximum_pending_task = get_env_var(\"MAXIMUM_IMPORT_PENDING_TASK\", \"3\")\n    .parse::<i64>()\n    .unwrap_or(3);\n\n  if count >= maximum_pending_task {\n    return Err(AppError::TooManyImportTask(format!(\n      \"{} tasks are pending. Please wait until they are completed\",\n      count\n    )));\n  }\n  Ok(())\n}\n\npub struct AutoDeletedFile {\n  name: String,\n  file_path: PathBuf,\n  size: usize,\n  md5_base64: String,\n}\n\nimpl Drop for AutoDeletedFile {\n  fn drop(&mut self) {\n    let path = self.file_path.clone();\n    tokio::spawn(async move {\n      trace!(\"[AutoDeletedFile]: delete file: {:?}\", path);\n      if let Err(err) = tokio::fs::remove_file(&path).await {\n        error!(\n          \"Failed to delete the auto deleted file: {:?}, error: {}\",\n          path, err\n        )\n      }\n    });\n  }\n}\npub async fn write_multiple_part(\n  payload: &mut Multipart,\n  file_path: PathBuf,\n) -> Result<AutoDeletedFile, AppError> {\n  let mut file_name = \"\".to_string();\n  let mut file_size = 0;\n\n  // Create the file to write to\n  let mut file = File::create(&file_path).await?;\n  let mut context = md5::Context::new();\n\n  // Process the multipart form fields\n  while let Some(Ok(mut field)) = payload.next().await {\n    // Extract the file name from the content disposition\n    file_name = field\n      .content_disposition()\n      .and_then(|c| c.get_name().map(|f| f.to_string()))\n      .unwrap_or_else(|| format!(\"import-{}\", chrono::Local::now().format(\"%d/%m/%Y %H:%M\")));\n\n    // Write data chunks to the file and update the MD5 context\n    while let Some(Ok(data)) = field.next().await {\n      file_size += data.len();\n      file.write_all(&data).await?;\n      context.consume(&data);\n    }\n  }\n\n  // Flush and close the file\n  file.shutdown().await?;\n  drop(file);\n\n  // If file_name is empty, remove the file and return an error\n  if file_name.is_empty() {\n    if let Err(err) = tokio::fs::remove_file(&file_path).await {\n      error!(\n        \"Failed to delete the file: {:?} when importing data, error: {}\",\n        file_path, err\n      );\n    }\n    return Err(AppError::InvalidRequest(\n      \"Cannot get the file name\".to_string(),\n    ));\n  }\n\n  // Finalize the MD5 hash and encode it in base64\n  let digest = context.compute();\n  let md5_base64 = STANDARD.encode(digest.as_ref());\n\n  // Return the file metadata and the calculated MD5 hash\n  Ok(AutoDeletedFile {\n    name: file_name,\n    file_path,\n    size: file_size,\n    md5_base64,\n  })\n}\n\nfn get_host_from_request(req: &HttpRequest) -> String {\n  req\n    .headers()\n    .get(\"X-Host\")\n    .and_then(|h| h.to_str().ok())\n    .unwrap_or(\"https://beta.appflowy.cloud\")\n    .to_string()\n}\n"
  },
  {
    "path": "src/api/file_storage.rs",
    "content": "use access_control::act::Action;\nuse actix_http::body::BoxBody;\nuse actix_web::http::header::{\n  ContentLength, ContentType, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE,\n  LAST_MODIFIED,\n};\nuse actix_web::web::{Json, Payload};\nuse actix_web::{\n  web::{self, Data},\n  HttpRequest, ResponseError, Scope,\n};\nuse actix_web::{HttpResponse, Result};\nuse app_error::AppError;\n\nuse chrono::DateTime;\nuse database::file::BlobKey;\nuse database::resource_usage::{get_all_workspace_blob_metadata, get_workspace_usage_size};\nuse database_entity::file_dto::{\n  CompleteUploadRequest, CreateUploadRequest, CreateUploadResponse, UploadPartData,\n  UploadPartResponse,\n};\n\nuse crate::biz::authentication::jwt::UserUuid;\nuse crate::biz::data_import::LimitedPayload;\nuse crate::state::AppState;\nuse anyhow::anyhow;\nuse appflowy_ai_client::client::AppFlowyAIClient;\nuse aws_sdk_s3::primitives::ByteStream;\nuse collab_importer::util::FileId;\nuse database::pg_row::{AFBlobSource, AFBlobStatus};\nuse serde::Deserialize;\nuse shared_entity::dto::file_dto::PutFileResponse;\nuse shared_entity::dto::workspace_dto::{BlobMetadata, RepeatedBlobMetaData, WorkspaceSpaceUsage};\nuse shared_entity::response::{AppResponse, AppResponseError, JsonAppResponse};\nuse sqlx::types::Uuid;\nuse std::pin::Pin;\nuse tokio::io::{AsyncRead, AsyncReadExt};\nuse tokio_stream::StreamExt;\nuse tokio_util::io::StreamReader;\nuse tracing::{error, event, info, instrument, trace};\n\npub fn file_storage_scope() -> Scope {\n  web::scope(\"/api/file_storage\")\n    .service(\n      // Deprecated, use put_blob_handler_v1 instead\n      web::resource(\"/{workspace_id}/blob/{file_id}\")\n        .route(web::put().to(put_blob_handler))\n        .route(web::get().to(get_blob_handler))\n        .route(web::delete().to(delete_blob_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/metadata/{file_id}\")\n        .route(web::get().to(get_blob_metadata_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/usage\").route(web::get().to(get_workspace_usage_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/blobs\")\n        .route(web::get().to(get_all_workspace_blob_metadata_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/create_upload\").route(web::post().to(create_upload)))\n    .service(\n      web::resource(\"/{workspace_id}/upload_part/{parent_dir}/{file_id}/{upload_id}/{part_num}\")\n        .route(web::put().to(upload_part_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/complete_upload\")\n        .route(web::put().to(complete_upload_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/v1/blob/{parent_dir}/{file_id}\")\n        .route(web::get().to(get_blob_v1_handler))\n        .route(web::delete().to(delete_blob_v1_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/v1/metadata/{parent_dir}/{file_id}\")\n        .route(web::get().to(get_blob_metadata_v1_handler)),\n    )\n    .service(\n      // Upload the file in a single request. This process combines the steps of create-upload,\n      // upload-part, and complete-upload into a single operation. The file can then be retrieved\n      // using the get_blob_v1_handler. If you want to support resumeable uploads, you should use\n      // the create-upload, upload-part, and complete-upload endpoints.\n      web::resource(\"/{workspace_id}/v1/blob/{parent_dir}\")\n        .route(web::put().to(put_blob_handler_v1)),\n    )\n}\n\n#[instrument(skip_all, err)]\nasync fn create_upload(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: web::Data<AppState>,\n  req: web::Json<CreateUploadRequest>,\n) -> Result<JsonAppResponse<CreateUploadResponse>> {\n  let req = req.into_inner();\n  if req.parent_dir.is_empty() {\n    return Err(AppError::InvalidRequest(\"parent_dir is empty\".to_string()).into());\n  }\n\n  if req.file_id.is_empty() {\n    return Err(AppError::InvalidRequest(\"file_id is empty\".to_string()).into());\n  }\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let key = BlobPathV1 {\n    workspace_id,\n    parent_dir: req.parent_dir.clone(),\n    file_id: req.file_id.clone(),\n  };\n  let resp = state\n    .bucket_storage\n    .create_upload(key, req)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\n#[derive(Deserialize)]\nstruct UploadPartPath {\n  workspace_id: Uuid,\n  parent_dir: String,\n  file_id: String,\n  upload_id: String,\n  part_num: i32,\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn upload_part_handler(\n  user_uuid: UserUuid,\n  path: web::Path<UploadPartPath>,\n  state: web::Data<AppState>,\n  content_length: web::Header<ContentLength>,\n  mut payload: Payload,\n) -> Result<JsonAppResponse<UploadPartResponse>> {\n  let path_params = path.into_inner();\n  trace!(\n    \"upload part: workspace_id: {}, parent_dir: {}, file_id: {}, upload_id: {}, part_num: {}\",\n    path_params.workspace_id,\n    path_params.parent_dir,\n    path_params.file_id,\n    path_params.upload_id,\n    path_params.part_num\n  );\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = path_params.workspace_id;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let content_length = content_length.into_inner().into_inner();\n  let mut content = Vec::with_capacity(content_length);\n  while let Some(chunk) = payload.try_next().await? {\n    content.extend_from_slice(&chunk);\n  }\n\n  if content.len() != content_length {\n    return Err(\n      AppError::InvalidRequest(format!(\n        \"Content length is {}, but received {} bytes\",\n        content_length,\n        content.len()\n      ))\n      .into(),\n    );\n  }\n  let data = UploadPartData {\n    file_id: path_params.file_id.clone(),\n    upload_id: path_params.upload_id,\n    part_number: path_params.part_num,\n    body: content,\n  };\n\n  let key = BlobPathV1 {\n    workspace_id,\n    parent_dir: path_params.parent_dir,\n    file_id: path_params.file_id,\n  };\n\n  let resp = state\n    .bucket_storage\n    .upload_part(key, data)\n    .await\n    .map_err(AppResponseError::from)?;\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\nasync fn complete_upload_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: web::Data<AppState>,\n  req: web::Json<CompleteUploadRequest>,\n) -> Result<JsonAppResponse<()>> {\n  let req = req.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let key = BlobPathV1 {\n    workspace_id,\n    parent_dir: req.parent_dir.clone(),\n    file_id: req.file_id.clone(),\n  };\n  state\n    .bucket_storage\n    .complete_upload(key, req)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(skip(state, payload), err)]\nasync fn put_blob_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<BlobPathV0>,\n  content_type: web::Header<ContentType>,\n  content_length: web::Header<ContentLength>,\n  payload: Payload,\n) -> Result<JsonAppResponse<()>> {\n  let path = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = path.workspace_id;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let content_length = content_length.into_inner().into_inner();\n  let content_type = content_type.into_inner().to_string();\n  let content = {\n    let mut payload_reader = payload_to_async_read(payload);\n    let mut content = Vec::with_capacity(content_length);\n    if content.try_reserve_exact(content_length).is_err() {\n      return Err(\n        AppError::Internal(anyhow!(\n          \"Can not alloc mem for blob content size:{}\",\n          content_length\n        ))\n        .into(),\n      );\n    }\n    content.resize(content_length, 0);\n\n    let n = payload_reader.read_exact(&mut content).await?;\n    if n != content_length {\n      error!(\n        \"Content length is {}, but the actual content is larger\",\n        content_length\n      );\n    }\n    let res = payload_reader.read_u8().await;\n    match res {\n      Ok(_) => {\n        return Ok(\n          AppResponse::from(AppError::PayloadTooLarge(\n            \"Content length is {}, but the actual content is larger\".to_string(),\n          ))\n          .into(),\n        );\n      },\n      Err(e) => match e.kind() {\n        std::io::ErrorKind::UnexpectedEof => (),\n        _ => return Err(AppError::Internal(anyhow::anyhow!(e)).into()),\n      },\n    };\n    content\n  };\n\n  event!(\n    tracing::Level::TRACE,\n    \"start put blob. workspace_id: {}, file_id: {}, content_length: {}\",\n    workspace_id,\n    path.file_id,\n    content_length\n  );\n\n  let file_size = content.len();\n  let file_stream = ByteStream::from(content);\n  state\n    .bucket_storage\n    .put_blob_with_content_type(path, file_stream, content_type, file_size)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn delete_blob_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<BlobPathV0>,\n) -> Result<JsonAppResponse<()>> {\n  let path = path.into_inner();\n  let workspace_id = path.workspace_id;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  state\n    .bucket_storage\n    .delete_blob(path)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_blob_v1_handler(\n  state: Data<AppState>,\n  path: web::Path<BlobPathV1>,\n  req: HttpRequest,\n) -> Result<HttpResponse<BoxBody>> {\n  let path = path.into_inner();\n  get_blob_by_object_key(state, &path, req).await\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn delete_blob_v1_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<BlobPathV1>,\n) -> Result<JsonAppResponse<()>> {\n  let path = path.into_inner();\n  let workspace_id = path.workspace_id;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  state\n    .bucket_storage\n    .delete_blob(path)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn get_blob_by_object_key(\n  state: Data<AppState>,\n  key: &impl BlobKey,\n  req: HttpRequest,\n) -> Result<HttpResponse<BoxBody>> {\n  // Get the metadata\n  let result = state\n    .bucket_storage\n    .get_blob_metadata(key.workspace_id(), &key.blob_metadata_key())\n    .await;\n\n  if let Err(err) = result.as_ref() {\n    return if err.is_record_not_found() {\n      Ok(HttpResponse::NotFound().finish())\n    } else {\n      Ok(HttpResponse::InternalServerError().finish())\n    };\n  }\n\n  let metadata = result.unwrap();\n  let source = AFBlobSource::from(metadata.source);\n  trace!(\"blob metadata: {:?}\", metadata);\n  match source {\n    AFBlobSource::UserUpload => {},\n    AFBlobSource::AIGen => {\n      let spawn_regenerate_image =\n        |client: AppFlowyAIClient, source_metadata: serde_json::Value| {\n          tokio::spawn(async move {\n            info!(\"Regenerate ai image: {:?}\", source_metadata);\n            let _ = client.regenerate_image(source_metadata).await;\n          });\n        };\n      let source_metadata = metadata.source_metadata;\n      let status = AFBlobStatus::from(metadata.status);\n      trace!(\"AI image {}: {:?}\", key.object_key(), status);\n      match status {\n        AFBlobStatus::PolicyViolation => {\n          return Ok(HttpResponse::UnprocessableEntity().finish());\n        },\n        AFBlobStatus::Pending => {\n          if metadata.modified_at + chrono::Duration::minutes(1) < chrono::Utc::now() {\n            spawn_regenerate_image(state.ai_client.clone(), source_metadata);\n          } else {\n            trace!(\"AI image is pending, wait for 1 minute\");\n          }\n        },\n        AFBlobStatus::Failed => {\n          spawn_regenerate_image(state.ai_client.clone(), source_metadata);\n        },\n        _ => {},\n      };\n    },\n  }\n\n  // Check if the file is modified since the last time\n  if let Some(modified_since) = req\n    .headers()\n    .get(IF_MODIFIED_SINCE)\n    .and_then(|h| h.to_str().ok())\n    .and_then(|s| DateTime::parse_from_rfc2822(s).ok())\n  {\n    if metadata.modified_at.naive_utc() <= modified_since.naive_utc() {\n      return Ok(HttpResponse::NotModified().finish());\n    }\n  }\n\n  trace!(\"Get blob data from bucket storage: {:?}\", key.object_key());\n  let blob_result = state.bucket_storage.get_blob(key).await;\n  match blob_result {\n    Ok(blob) => {\n      let response = HttpResponse::Ok()\n          .append_header((ETAG, key.e_tag()))\n          .append_header((CONTENT_TYPE, metadata.file_type))\n          .append_header((LAST_MODIFIED, metadata.modified_at.to_rfc2822()))\n          .append_header((CONTENT_LENGTH, blob.len()))\n          .append_header((CACHE_CONTROL, \"public, immutable, max-age=31536000\"))// 31536000 seconds = 1 year\n          .body(blob);\n\n      Ok(response)\n    },\n    Err(err) => {\n      if err.is_record_not_found() {\n        Ok(HttpResponse::NotFound().finish())\n      } else {\n        Ok(AppResponseError::from(err).error_response())\n      }\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_blob_handler(\n  state: Data<AppState>,\n  path: web::Path<BlobPathV0>,\n  req: HttpRequest,\n) -> Result<HttpResponse<BoxBody>> {\n  let blob_path = path.into_inner();\n  get_blob_by_object_key(state, &blob_path, req).await\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_blob_metadata_handler(\n  state: Data<AppState>,\n  path: web::Path<BlobPathV0>,\n) -> Result<JsonAppResponse<BlobMetadata>> {\n  let path = path.into_inner();\n\n  // Get the metadata\n  let metadata = state\n    .bucket_storage\n    .get_blob_metadata(&path.workspace_id, &path.blob_metadata_key())\n    .await\n    .map(|meta| BlobMetadata {\n      workspace_id: meta.workspace_id,\n      file_id: meta.file_id,\n      file_type: meta.file_type,\n      file_size: meta.file_size,\n      modified_at: meta.modified_at,\n    })\n    .map_err(AppResponseError::from)?;\n\n  Ok(Json(AppResponse::Ok().with_data(metadata)))\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_blob_metadata_v1_handler(\n  state: Data<AppState>,\n  path: web::Path<BlobPathV1>,\n) -> Result<JsonAppResponse<BlobMetadata>> {\n  let path = path.into_inner();\n\n  // Get the metadata\n  let metadata = state\n    .bucket_storage\n    .get_blob_metadata(&path.workspace_id, &path.blob_metadata_key())\n    .await\n    .map(|meta| BlobMetadata {\n      workspace_id: meta.workspace_id,\n      file_id: meta.file_id,\n      file_type: meta.file_type,\n      file_size: meta.file_size,\n      modified_at: meta.modified_at,\n    })\n    .map_err(AppResponseError::from)?;\n\n  Ok(Json(AppResponse::Ok().with_data(metadata)))\n}\n\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_workspace_usage_handler(\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<WorkspaceSpaceUsage>> {\n  let current = get_workspace_usage_size(&state.pg_pool, &workspace_id)\n    .await\n    .map_err(AppResponseError::from)?;\n  let usage = WorkspaceSpaceUsage {\n    consumed_capacity: current,\n  };\n  Ok(AppResponse::Ok().with_data(usage).into())\n}\n\n// TODO(nathan): implement pagination\n#[instrument(level = \"debug\", skip(state), err)]\nasync fn get_all_workspace_blob_metadata_handler(\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<RepeatedBlobMetaData>> {\n  let metas = get_all_workspace_blob_metadata(&state.pg_pool, &workspace_id)\n    .await\n    .map_err(AppResponseError::from)?\n    .into_iter()\n    .map(|meta| BlobMetadata {\n      workspace_id: meta.workspace_id,\n      file_id: meta.file_id,\n      file_type: meta.file_type,\n      file_size: meta.file_size,\n      modified_at: meta.modified_at,\n    })\n    .collect::<Vec<_>>();\n  Ok(\n    AppResponse::Ok()\n      .with_data(RepeatedBlobMetaData(metas))\n      .into(),\n  )\n}\nfn payload_to_async_read(payload: Payload) -> Pin<Box<dyn AsyncRead>> {\n  let mapped =\n    payload.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));\n  let reader = StreamReader::new(mapped);\n  Box::pin(reader)\n}\n\n#[instrument(skip(state, payload), err)]\nasync fn put_blob_handler_v1(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<BlobPathV2>,\n  content_type: web::Header<ContentType>,\n  content_length: web::Header<ContentLength>,\n  payload: Payload,\n) -> Result<JsonAppResponse<PutFileResponse>> {\n  let path = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &path.workspace_id, Action::Write)\n    .await?;\n\n  let content_length = content_length.into_inner().into_inner();\n  let content_type = content_type.into_inner().to_string();\n\n  let mut content = Vec::with_capacity(content_length);\n  if content.try_reserve_exact(content_length).is_err() {\n    return Err(\n      AppError::Internal(anyhow!(\n        \"Can not alloc mem for blob content size:{}\",\n        content_length\n      ))\n      .into(),\n    );\n  }\n  content.resize(content_length, 0);\n\n  let mut limited_payload = LimitedPayload::new(payload, content_length);\n  let mut offset = 0;\n  while let Some(bytes) = limited_payload.next().await {\n    let bytes = bytes?;\n    let len = bytes.len();\n    content[offset..offset + len].copy_from_slice(&bytes);\n    offset += len;\n  }\n\n  let file_id = FileId::from_bytes(&content, \"\".to_string());\n  let resp_data = PutFileResponse {\n    file_id: file_id.clone(),\n  };\n  event!(\n    tracing::Level::TRACE,\n    \"start put blob. workspace_id: {}, file_id: {}, content_length: {}\",\n    path.workspace_id,\n    file_id,\n    content_length\n  );\n\n  let file_stream = ByteStream::from(content);\n  state\n    .bucket_storage\n    .put_blob_with_content_type(\n      BlobPathV1::from((path, file_id)),\n      file_stream,\n      content_type,\n      content_length,\n    )\n    .await\n    .map_err(AppResponseError::from)?;\n  Ok(AppResponse::Ok().with_data(resp_data).into())\n}\n\n/// Use [BlobPathV0] when get/put object by single part\n#[derive(Deserialize, Debug)]\nstruct BlobPathV0 {\n  workspace_id: Uuid,\n  file_id: String,\n}\n\nimpl BlobKey for BlobPathV0 {\n  fn workspace_id(&self) -> &Uuid {\n    &self.workspace_id\n  }\n\n  fn object_key(&self) -> String {\n    format!(\"{}/{}\", self.workspace_id, self.file_id)\n  }\n\n  fn blob_metadata_key(&self) -> String {\n    self.file_id.clone()\n  }\n\n  fn e_tag(&self) -> &str {\n    &self.file_id\n  }\n}\n\n/// Use [BlobPathV1] when put/get object by multiple upload parts\n#[derive(Deserialize, Debug)]\npub struct BlobPathV1 {\n  pub workspace_id: Uuid,\n  pub parent_dir: String,\n  pub file_id: String,\n}\n\nimpl BlobKey for BlobPathV1 {\n  fn workspace_id(&self) -> &Uuid {\n    &self.workspace_id\n  }\n\n  fn object_key(&self) -> String {\n    format!(\"{}/{}/{}\", self.workspace_id, self.parent_dir, self.file_id)\n  }\n\n  fn blob_metadata_key(&self) -> String {\n    format!(\"{}_{}\", self.parent_dir, self.file_id)\n  }\n\n  fn e_tag(&self) -> &str {\n    &self.file_id\n  }\n}\n\n#[derive(Deserialize, Debug)]\npub struct BlobPathV2 {\n  pub workspace_id: Uuid,\n  pub parent_dir: String,\n}\n\nimpl From<(BlobPathV2, String)> for BlobPathV1 {\n  fn from((path, file_id): (BlobPathV2, String)) -> Self {\n    BlobPathV1 {\n      workspace_id: path.workspace_id,\n      parent_dir: path.parent_dir,\n      file_id,\n    }\n  }\n}\n"
  },
  {
    "path": "src/api/guest.rs",
    "content": "use actix_web::{\n  web::{Data, Json},\n  Result,\n};\nuse app_error::ErrorCode;\nuse shared_entity::{\n  dto::guest_dto::{\n    RevokeSharedViewAccessRequest, ShareViewWithGuestRequest, SharedViewDetails,\n    SharedViewDetailsRequest, SharedViews,\n  },\n  response::{AppResponseError, JsonAppResponse},\n};\n\nuse actix_web::{\n  web::{self},\n  Scope,\n};\nuse uuid::Uuid;\n\nuse crate::biz::authentication::jwt::UserUuid;\nuse crate::state::AppState;\n\npub fn sharing_scope() -> Scope {\n  web::scope(\"/api/sharing/workspace\")\n    .service(\n      web::resource(\"{workspace_id}/view\")\n        .route(web::get().to(list_shared_views_handler))\n        .route(web::put().to(put_shared_view_handler)),\n    )\n    .service(\n      web::resource(\"{workspace_id}/view/{view_id}/access-details\")\n        .route(web::post().to(shared_view_access_details_handler)),\n    )\n    .service(\n      web::resource(\"{workspace_id}/view/{view_id}/revoke-access\")\n        .route(web::post().to(revoke_shared_view_access_handler)),\n    )\n}\n\nasync fn list_shared_views_handler(\n  _user_uuid: UserUuid,\n  _state: Data<AppState>,\n  _path: web::Path<Uuid>,\n) -> Result<JsonAppResponse<SharedViews>> {\n  Err(\n    AppResponseError::new(\n      ErrorCode::FeatureNotAvailable,\n      \"this version of appflowy cloud server does not support guest editors\",\n    )\n    .into(),\n  )\n}\n\nasync fn put_shared_view_handler(\n  _user_uuid: UserUuid,\n  _state: Data<AppState>,\n  _payload: web::Json<ShareViewWithGuestRequest>,\n  _path: web::Path<Uuid>,\n) -> Result<JsonAppResponse<()>> {\n  Err(\n    AppResponseError::new(\n      ErrorCode::FeatureNotAvailable,\n      \"this version of appflowy cloud server does not support guest editors\",\n    )\n    .into(),\n  )\n}\n\nasync fn shared_view_access_details_handler(\n  _user_uuid: UserUuid,\n  _state: Data<AppState>,\n  _json: Json<SharedViewDetailsRequest>,\n  _path: web::Path<(Uuid, Uuid)>,\n) -> Result<JsonAppResponse<SharedViewDetails>> {\n  Err(\n    AppResponseError::new(\n      ErrorCode::FeatureNotAvailable,\n      \"this version of appflowy cloud server does not support guest editors\",\n    )\n    .into(),\n  )\n}\n\nasync fn revoke_shared_view_access_handler(\n  _user_uuid: UserUuid,\n  _state: Data<AppState>,\n  _payload: web::Json<RevokeSharedViewAccessRequest>,\n  _path: web::Path<(Uuid, Uuid)>,\n) -> Result<JsonAppResponse<()>> {\n  Err(\n    AppResponseError::new(\n      ErrorCode::FeatureNotAvailable,\n      \"this version of appflowy cloud server does not support guest editors\",\n    )\n    .into(),\n  )\n}\n"
  },
  {
    "path": "src/api/invite_code.rs",
    "content": "use actix_web::{\n  web::{self, Data, Json},\n  Result, Scope,\n};\n\nuse database_entity::dto::{GetInvitationCodeInfoQuery, InvitationCodeInfo};\nuse shared_entity::response::{AppResponse, JsonAppResponse};\n\nuse crate::{\n  biz::{authentication::jwt::UserUuid, workspace::invite::get_invitation_code_info},\n  state::AppState,\n};\n\npub fn invite_code_scope() -> Scope {\n  web::scope(\"/api/invite-code-info\")\n    .service(web::resource(\"\").route(web::get().to(get_invite_code_info_handler)))\n}\n\nasync fn get_invite_code_info_handler(\n  user_uuid: UserUuid,\n  query: web::Query<GetInvitationCodeInfoQuery>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<InvitationCodeInfo>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let info = get_invitation_code_info(&state.pg_pool, &query.code, uid).await?;\n  Ok(Json(AppResponse::Ok().with_data(info)))\n}\n"
  },
  {
    "path": "src/api/metrics.rs",
    "content": "use actix_web::web;\nuse actix_web::HttpResponse;\nuse actix_web::Result;\nuse actix_web::Scope;\nuse prometheus_client::encoding::text::encode;\nuse prometheus_client::encoding::EncodeLabelSet;\nuse prometheus_client::metrics::counter::Counter;\nuse prometheus_client::metrics::exemplar::CounterWithExemplar;\nuse prometheus_client::metrics::family::Family;\nuse prometheus_client::metrics::gauge::Gauge;\nuse prometheus_client::metrics::histogram::exponential_buckets;\nuse prometheus_client::metrics::histogram::Histogram;\nuse prometheus_client::registry::Registry;\nuse std::sync::Arc;\nuse uuid::Uuid;\n\npub fn metrics_scope() -> Scope {\n  web::scope(\"/metrics\").service(web::resource(\"\").route(web::get().to(metrics_handler)))\n}\n\nasync fn metrics_handler(reg: web::Data<Arc<Registry>>) -> Result<HttpResponse> {\n  let mut body = String::new();\n  encode(&mut body, &reg).map_err(|e| {\n    tracing::error!(\"Failed to encode metrics: {:?}\", e);\n    actix_web::error::ErrorInternalServerError(e)\n  })?;\n  Ok(\n    HttpResponse::Ok()\n      .content_type(\"application/openmetrics-text; version=1.0.0; charset=utf-8\")\n      .body(body),\n  )\n}\n\n#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]\npub struct PathLabel {\n  pub path: String,\n  pub method: String,\n}\n\n#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]\npub struct ResultLabel {\n  pub path: String,\n  pub method: String,\n  pub status_code: u16,\n}\n\n#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)]\npub struct WorkspaceLabel {\n  pub workspace: String,\n}\n\n// Metrics contains list of metrics that are collected by the application.\n// Metric types: https://prometheus.io/docs/concepts/metric_types\n// Application handlers should call the corresponding methods to update the metrics.\n#[derive(Clone)]\npub struct RequestMetrics {\n  requests_count: Family<PathLabel, Counter>,\n  requests_latency: Family<PathLabel, CounterWithExemplar<TraceLabel>>,\n  requests_result: Family<ResultLabel, CounterWithExemplar<TraceLabel>>,\n  openai_token_usage: Family<WorkspaceLabel, Counter>,\n}\n\n#[derive(Clone, Hash, PartialEq, Eq, EncodeLabelSet, Debug, Default)]\npub struct TraceLabel {\n  pub trace_id: String,\n}\n\nimpl RequestMetrics {\n  fn init() -> Self {\n    Self {\n      requests_count: Family::default(),\n      requests_latency: Family::default(),\n      requests_result: Family::default(),\n      openai_token_usage: Family::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let af_metrics = Self::init();\n\n    let af_registry = registry.sub_registry_with_prefix(\"appflowy_cloud\");\n    af_registry.register(\n      \"requests_count\",\n      \"number of requests\",\n      af_metrics.requests_count.clone(),\n    );\n    af_registry.register(\n      \"requests_latency\",\n      \"request response time\",\n      af_metrics.requests_latency.clone(),\n    );\n    af_registry.register(\n      \"requests_result\",\n      \"status code of response\",\n      af_metrics.requests_result.clone(),\n    );\n    af_registry.register(\n      \"search_tokens_used\",\n      \"OpenAI API tokens used for search requests\",\n      af_metrics.openai_token_usage.clone(),\n    );\n    af_metrics\n  }\n\n  pub fn record_search_tokens_used(&self, workspace_id: &Uuid, tokens: u32) {\n    self\n      .openai_token_usage\n      .get_or_create(&WorkspaceLabel {\n        workspace: workspace_id.to_string(),\n      })\n      .inc_by(tokens as u64);\n  }\n\n  // app services/middleware should call this method to increase the request count for the path\n  pub fn record_request(\n    &self,\n    trace_id: Option<String>,\n    path: String,\n    method: String,\n    ms: u64,\n    status_code: u16,\n  ) {\n    self\n      .requests_count\n      .get_or_create(&PathLabel {\n        path: path.clone(),\n        method: method.clone(),\n      })\n      .inc();\n    self\n      .requests_latency\n      .get_or_create(&PathLabel {\n        path: path.clone(),\n        method: method.clone(),\n      })\n      .inc_by(ms, trace_id.clone().map(|s| TraceLabel { trace_id: s }));\n    self\n      .requests_result\n      .get_or_create(&ResultLabel {\n        path,\n        method,\n        status_code,\n      })\n      .inc_by(1, trace_id.clone().map(|s| TraceLabel { trace_id: s }));\n  }\n}\n\n#[derive(Clone)]\npub struct PublishedCollabMetrics {\n  success_write_published_collab_count: Gauge,\n  fallback_write_published_collab_count: Gauge,\n  failure_write_published_collab_count: Gauge,\n  success_read_published_collab_count: Gauge,\n  fallback_read_published_collab_count: Gauge,\n  failure_read_published_collab_count: Gauge,\n}\n\nimpl PublishedCollabMetrics {\n  fn init() -> Self {\n    Self {\n      success_write_published_collab_count: Default::default(),\n      fallback_write_published_collab_count: Default::default(),\n      failure_write_published_collab_count: Default::default(),\n      success_read_published_collab_count: Default::default(),\n      fallback_read_published_collab_count: Default::default(),\n      failure_read_published_collab_count: Default::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::init();\n    let published_collab_registry = registry.sub_registry_with_prefix(\"published_collab\");\n    published_collab_registry.register(\n      \"write_success_count\",\n      \"successfully published collab\",\n      metrics.success_write_published_collab_count.clone(),\n    );\n    published_collab_registry.register(\n      \"write_fallback_count\",\n      \"successfully published collab to fallback store\",\n      metrics.fallback_write_published_collab_count.clone(),\n    );\n    published_collab_registry.register(\n      \"read_success_count\",\n      \"successfully read published collab\",\n      metrics.success_read_published_collab_count.clone(),\n    );\n    published_collab_registry.register(\n      \"write_failure_count\",\n      \"failed to publish collab\",\n      metrics.failure_write_published_collab_count.clone(),\n    );\n    published_collab_registry.register(\n      \"read_failure_count\",\n      \"failed to read published collab\",\n      metrics.failure_read_published_collab_count.clone(),\n    );\n    published_collab_registry.register(\n      \"read_fallback_count\",\n      \"failed to read published collab from primary store\",\n      metrics.fallback_read_published_collab_count.clone(),\n    );\n\n    metrics\n  }\n\n  pub fn incr_success_write_count(&self, count: i64) {\n    self.success_write_published_collab_count.inc_by(count);\n  }\n\n  pub fn incr_fallback_write_count(&self, count: i64) {\n    self.fallback_write_published_collab_count.inc_by(count);\n  }\n\n  pub fn incr_failure_write_count(&self, count: i64) {\n    self.failure_write_published_collab_count.inc_by(count);\n  }\n\n  pub fn incr_success_read_count(&self, count: i64) {\n    self.success_read_published_collab_count.inc_by(count);\n  }\n\n  pub fn incr_fallback_read_count(&self, count: i64) {\n    self.fallback_read_published_collab_count.inc_by(count);\n  }\n\n  pub fn incr_failure_read_count(&self, count: i64) {\n    self.failure_read_published_collab_count.inc_by(count);\n  }\n}\n\npub struct AppFlowyWebMetrics {\n  pub update_size_bytes: Histogram,\n  pub decoding_failure_count: Gauge,\n  pub apply_update_failure_count: Gauge,\n  pub apply_update_timeout_count: Gauge,\n}\n\nimpl AppFlowyWebMetrics {\n  pub fn init() -> Self {\n    let update_size_buckets = exponential_buckets(1024.0, 2.0, 10);\n\n    Self {\n      update_size_bytes: Histogram::new(update_size_buckets),\n      decoding_failure_count: Default::default(),\n      apply_update_failure_count: Default::default(),\n      apply_update_timeout_count: Default::default(),\n    }\n  }\n\n  pub fn register(registry: &mut Registry) -> Self {\n    let metrics = Self::init();\n    let web_update_registry = registry.sub_registry_with_prefix(\"appflowy_web\");\n    web_update_registry.register(\n      \"update_size_bytes\",\n      \"Size of the update in bytes\",\n      metrics.update_size_bytes.clone(),\n    );\n    web_update_registry.register(\n      \"decoding_failure_count\",\n      \"Number of updates that failed to decode\",\n      metrics.decoding_failure_count.clone(),\n    );\n    web_update_registry.register(\n      \"apply_update_failure_count\",\n      \"Number of updates that failed to apply\",\n      metrics.apply_update_failure_count.clone(),\n    );\n    web_update_registry.register(\n      \"apply_update_timeout_count\",\n      \"Number of updates that failed to apply within timeout\",\n      metrics.apply_update_timeout_count.clone(),\n    );\n    metrics\n  }\n\n  pub fn record_update_size_bytes(&self, size: usize) {\n    self.update_size_bytes.observe(size as f64);\n  }\n\n  pub fn incr_decoding_failure_count(&self, count: i64) {\n    self.decoding_failure_count.inc_by(count);\n  }\n\n  pub fn incr_apply_update_failure_count(&self, count: i64) {\n    self.apply_update_failure_count.inc_by(count);\n  }\n\n  pub fn incr_apply_update_timeout_count(&self, count: i64) {\n    self.apply_update_timeout_count.inc_by(count);\n  }\n}\n"
  },
  {
    "path": "src/api/mod.rs",
    "content": "pub mod access_request;\npub mod ai;\npub mod chat;\npub mod data_import;\npub mod file_storage;\npub mod guest;\npub mod invite_code;\npub mod metrics;\npub mod search;\npub mod server_info;\npub mod template;\npub mod user;\npub mod util;\npub mod workspace;\npub mod ws;\n"
  },
  {
    "path": "src/api/search.rs",
    "content": "use crate::biz::authentication::jwt::Authorization;\nuse crate::biz::search::{search_document, summarize_search_results};\nuse crate::state::AppState;\nuse access_control::act::Action;\nuse actix_web::web::{Data, Json, Query};\nuse actix_web::{web, Scope};\nuse async_openai::config::{AzureConfig, OpenAIConfig};\n\nuse llm_client::chat::{AITool, AzureOpenAIChat, OpenAIChat};\nuse shared_entity::dto::search_dto::{\n  SearchDocumentRequest, SearchDocumentResponseItem, SearchSummaryResult,\n  SummarySearchResultRequest,\n};\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse uuid::Uuid;\n\npub fn search_scope() -> Scope {\n  web::scope(\"/api/search\")\n    .service(web::resource(\"{workspace_id}\").route(web::get().to(document_search_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/summary\").route(web::get().to(summary_search_results_handler)),\n    )\n}\n\n#[tracing::instrument(skip(state, auth, payload), err)]\nasync fn document_search_handler(\n  auth: Authorization,\n  path: web::Path<Uuid>,\n  payload: Query<SearchDocumentRequest>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<Vec<SearchDocumentResponseItem>>> {\n  let workspace_id = path.into_inner();\n  let request = payload.into_inner();\n  let user_uuid = auth.uuid()?;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let metrics = &*state.metrics.request_metrics;\n  let resp = search_document(\n    &state.pg_pool,\n    &state.ws_server,\n    &state.indexer_scheduler,\n    uid,\n    workspace_id,\n    request,\n    metrics,\n  )\n  .await?;\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\n#[tracing::instrument(skip(state, auth, payload), err)]\nasync fn summary_search_results_handler(\n  auth: Authorization,\n  path: web::Path<Uuid>,\n  payload: Json<SummarySearchResultRequest>,\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<SearchSummaryResult>> {\n  let workspace_id = path.into_inner();\n  let request = payload.into_inner();\n  let user_uuid = auth.uuid()?;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n\n  let ai_tool = create_ai_tool(&state.config.azure_ai_config, &state.config.open_ai_config);\n  let result = summarize_search_results(ai_tool, request).await?;\n  Ok(AppResponse::Ok().with_data(result).into())\n}\n\npub fn create_ai_tool(\n  azure_ai_config: &Option<AzureConfig>,\n  open_ai_config: &Option<OpenAIConfig>,\n) -> Option<AITool> {\n  if let Some(config) = &azure_ai_config {\n    return Some(AITool::AzureOpenAI(AzureOpenAIChat::new(config.clone())));\n  }\n\n  if let Some(config) = &open_ai_config {\n    return Some(AITool::OpenAI(OpenAIChat::new(config.clone())));\n  }\n  None\n}\n"
  },
  {
    "path": "src/api/server_info.rs",
    "content": "use actix_web::web::Data;\nuse actix_web::{web, Scope};\nuse shared_entity::dto::server_info_dto::ServerInfoResponseItem;\nuse shared_entity::response::{AppResponse, JsonAppResponse};\n\nuse crate::state::AppState;\n\npub fn server_info_scope() -> Scope {\n  web::scope(\"/api/server\").service(web::resource(\"\").route(web::get().to(server_info_handler)))\n}\n\nasync fn server_info_handler(\n  state: Data<AppState>,\n) -> actix_web::Result<JsonAppResponse<ServerInfoResponseItem>> {\n  Ok(\n    AppResponse::Ok()\n      .with_data(ServerInfoResponseItem {\n        supported_client_features: vec![],\n        minimum_supported_client_version: None,\n        appflowy_web_url: state.config.appflowy_web_url.clone(),\n      })\n      .into(),\n  )\n}\n"
  },
  {
    "path": "src/api/template.rs",
    "content": "use actix_multipart::form::{bytes::Bytes as MPBytes, MultipartForm};\nuse actix_web::http::StatusCode;\nuse actix_web::{\n  web::{self, Data, Json},\n  HttpResponse, Result, Scope,\n};\n\nuse database_entity::dto::{\n  AvatarImageSource, CreateTemplateCategoryParams, CreateTemplateCreatorParams,\n  CreateTemplateParams, GetTemplateCategoriesQueryParams, GetTemplateCreatorsQueryParams,\n  GetTemplatesQueryParams, Template, TemplateCategories, TemplateCategory, TemplateCreator,\n  TemplateCreators, TemplateHomePage, TemplateHomePageQueryParams, TemplateWithPublishInfo,\n  Templates, UpdateTemplateCategoryParams, UpdateTemplateCreatorParams, UpdateTemplateParams,\n};\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse uuid::Uuid;\n\nuse crate::biz::authentication::jwt::UserUuid;\nuse crate::{biz::template::ops::*, state::AppState};\n\npub fn template_scope() -> Scope {\n  web::scope(\"/api/template-center\")\n    .service(\n      web::resource(\"/category\")\n        .route(web::post().to(post_template_category_handler))\n        .route(web::get().to(list_template_categories_handler)),\n    )\n    .service(\n      web::resource(\"/category/{category_id}\")\n        .route(web::put().to(update_template_category_handler))\n        .route(web::get().to(get_template_category_handler))\n        .route(web::delete().to(delete_template_category_handler)),\n    )\n    .service(\n      web::resource(\"/creator\")\n        .route(web::post().to(post_template_creator_handler))\n        .route(web::get().to(list_template_creators_handler)),\n    )\n    .service(\n      web::resource(\"/creator/{creator_id}\")\n        .route(web::put().to(update_template_creator_handler))\n        .route(web::get().to(get_template_creator_handler))\n        .route(web::delete().to(delete_template_creator_handler)),\n    )\n    .service(\n      web::resource(\"/template\")\n        .route(web::post().to(post_template_handler))\n        .route(web::get().to(list_templates_handler)),\n    )\n    .service(\n      web::resource(\"/template/{view_id}\")\n        .route(web::put().to(update_template_handler))\n        .route(web::get().to(get_template_handler))\n        .route(web::delete().to(delete_template_handler)),\n    )\n    .service(web::resource(\"/homepage\").route(web::get().to(get_template_homepage_handler)))\n    .service(web::resource(\"/avatar\").route(web::put().to(put_avatar_handler)))\n    .service(web::resource(\"/avatar/{avatar_id}\").route(web::get().to(get_avatar_handler)))\n}\n\nasync fn post_template_category_handler(\n  _uuid: UserUuid,\n  data: Json<CreateTemplateCategoryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCategory>> {\n  let new_template_category = create_new_template_category(\n    &state.pg_pool,\n    &data.name,\n    &data.description,\n    &data.icon,\n    &data.bg_color,\n    data.category_type,\n    data.priority,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(new_template_category)))\n}\n\nasync fn list_template_categories_handler(\n  query: web::Query<GetTemplateCategoriesQueryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCategories>> {\n  let categories = get_template_categories(\n    &state.pg_pool,\n    query.name_contains.as_deref(),\n    query.category_type,\n  )\n  .await?;\n  Ok(Json(\n    AppResponse::Ok().with_data(TemplateCategories { categories }),\n  ))\n}\n\nasync fn update_template_category_handler(\n  _uuid: UserUuid,\n  category_id: web::Path<Uuid>,\n  data: Json<UpdateTemplateCategoryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCategory>> {\n  let category_id = category_id.into_inner();\n  let updated_template_category = update_template_category(\n    &state.pg_pool,\n    category_id,\n    &data.name,\n    &data.description,\n    &data.icon,\n    &data.bg_color,\n    data.category_type,\n    data.priority,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(updated_template_category)))\n}\n\nasync fn get_template_category_handler(\n  category_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCategory>> {\n  let category_id = category_id.into_inner();\n  let category = get_template_category(&state.pg_pool, category_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(category)))\n}\n\nasync fn delete_template_category_handler(\n  _uuid: UserUuid,\n  category_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let category_id = category_id.into_inner();\n  delete_template_category(&state.pg_pool, category_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn post_template_creator_handler(\n  _uuid: UserUuid,\n  data: Json<CreateTemplateCreatorParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCreator>> {\n  let new_template_creator = create_new_template_creator(\n    &state.pg_pool,\n    &data.name,\n    &data.avatar_url,\n    &data.account_links,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(new_template_creator)))\n}\n\nasync fn list_template_creators_handler(\n  query: web::Query<GetTemplateCreatorsQueryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCreators>> {\n  let creators = get_template_creators(&state.pg_pool, &query.name_contains).await?;\n  Ok(Json(\n    AppResponse::Ok().with_data(TemplateCreators { creators }),\n  ))\n}\n\nasync fn update_template_creator_handler(\n  _uuid: UserUuid,\n  creator_id: web::Path<Uuid>,\n  data: Json<UpdateTemplateCreatorParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCreator>> {\n  let creator_id = creator_id.into_inner();\n  let updated_creator = update_template_creator(\n    &state.pg_pool,\n    creator_id,\n    &data.name,\n    &data.avatar_url,\n    &data.account_links,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(updated_creator)))\n}\n\nasync fn get_template_creator_handler(\n  creator_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCreator>> {\n  let creator_id = creator_id.into_inner();\n  let template_creator = get_template_creator(&state.pg_pool, creator_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(template_creator)))\n}\n\nasync fn delete_template_creator_handler(\n  _uuid: UserUuid,\n  creator_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateCreator>> {\n  let creator_id = creator_id.into_inner();\n  delete_template_creator(&state.pg_pool, creator_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn post_template_handler(\n  _uuid: UserUuid,\n  data: Json<CreateTemplateParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<Template>> {\n  let new_template = create_new_template(\n    &state.pg_pool,\n    data.view_id,\n    &data.name,\n    &data.description,\n    &data.about,\n    &data.view_url,\n    data.creator_id,\n    data.is_new_template,\n    data.is_featured,\n    &data.category_ids,\n    &data.related_view_ids,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(new_template)))\n}\n\nasync fn list_templates_handler(\n  data: web::Query<GetTemplatesQueryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<Templates>> {\n  let data = data.into_inner();\n  let template_summary_list = get_templates_with_publish_info(\n    &state.pg_pool,\n    data.category_id,\n    data.is_featured,\n    data.is_new_template,\n    data.name_contains.as_deref(),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(Templates {\n    templates: template_summary_list,\n  })))\n}\n\nasync fn get_template_handler(\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateWithPublishInfo>> {\n  let view_id = view_id.into_inner();\n  let template_with_pub_info = get_template_with_publish_info(&state.pg_pool, view_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(template_with_pub_info)))\n}\n\nasync fn update_template_handler(\n  _uuid: UserUuid,\n  view_id: web::Path<Uuid>,\n  data: Json<UpdateTemplateParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<Template>> {\n  let view_id = view_id.into_inner();\n  let updated_template = update_template(\n    &state.pg_pool,\n    view_id,\n    &data.name,\n    &data.description,\n    &data.about,\n    &data.view_url,\n    data.creator_id,\n    data.is_new_template,\n    data.is_featured,\n    &data.category_ids,\n    &data.related_view_ids,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(updated_template)))\n}\n\nasync fn delete_template_handler(\n  _uuid: UserUuid,\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let view_id = view_id.into_inner();\n  delete_template(&state.pg_pool, view_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_template_homepage_handler(\n  query: web::Query<TemplateHomePageQueryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<TemplateHomePage>> {\n  let template_homepage = get_template_homepage(&state.pg_pool, query.per_count).await?;\n  Ok(Json(AppResponse::Ok().with_data(template_homepage)))\n}\n\n#[derive(MultipartForm)]\n#[multipart(duplicate_field = \"deny\")]\nstruct UploadAvatarForm {\n  #[multipart(limit = \"150KB\")]\n  avatar: MPBytes,\n}\n\nasync fn get_avatar_handler(\n  file_id: web::Path<String>,\n  state: Data<AppState>,\n) -> Result<HttpResponse> {\n  let file_id = file_id.into_inner();\n  let avatar = get_avatar(state.bucket_client.clone(), file_id).await?;\n  Ok(\n    HttpResponse::build(StatusCode::OK)\n      .content_type(avatar.content_type)\n      .body(avatar.data),\n  )\n}\n\nasync fn put_avatar_handler(\n  _uuid: UserUuid,\n  MultipartForm(form): MultipartForm<UploadAvatarForm>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<AvatarImageSource>> {\n  let file_id = upload_avatar(state.bucket_client.clone(), &form.avatar).await?;\n  Ok(Json(\n    AppResponse::Ok().with_data(AvatarImageSource { file_id }),\n  ))\n}\n"
  },
  {
    "path": "src/api/user.rs",
    "content": "use crate::api::util::client_version_from_headers;\nuse crate::biz::authentication::jwt::{Authorization, UserUuid};\nuse crate::biz::user::image_asset::{get_user_image_asset, upload_user_image_asset};\nuse crate::biz::user::user_delete::delete_user;\nuse crate::biz::user::user_info::{get_profile, get_user_workspace_info, update_user};\nuse crate::biz::user::user_verify::verify_token;\nuse crate::state::AppState;\nuse actix_http::StatusCode;\nuse actix_multipart::form::bytes::Bytes;\nuse actix_multipart::form::MultipartForm;\nuse actix_web::web::{Data, Json};\nuse actix_web::{web, HttpResponse, Scope};\nuse actix_web::{HttpRequest, Result};\nuse database_entity::dto::{AFUserProfile, AFUserWorkspaceInfo, UserImageAssetSource};\nuse semver::Version;\nuse shared_entity::dto::auth_dto::{DeleteUserQuery, SignInTokenResponse, UpdateUserParams};\nuse shared_entity::response::AppResponseError;\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse uuid::Uuid;\n\npub fn user_scope() -> Scope {\n  web::scope(\"/api/user\")\n    .service(web::resource(\"/verify/{access_token}\").route(web::get().to(verify_user_handler)))\n    .service(web::resource(\"/update\").route(web::post().to(update_user_handler)))\n    .service(web::resource(\"/profile\").route(web::get().to(get_user_profile_handler)))\n    .service(web::resource(\"/workspace\").route(web::get().to(get_user_workspace_info_handler)))\n    .service(web::resource(\"/asset/image\").route(web::post().to(post_user_image_asset_handler)))\n    .service(\n      web::resource(\"/asset/image/person/{person_id}/file/{file_id}\")\n        .route(web::get().to(get_user_image_asset_handler)),\n    )\n    .service(web::resource(\"\").route(web::delete().to(delete_user_handler)))\n}\n\n#[tracing::instrument(skip(state, path), err)]\nasync fn verify_user_handler(\n  path: web::Path<String>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<SignInTokenResponse>> {\n  let access_token = path.into_inner();\n  let is_new = verify_token(&access_token, state.as_ref())\n    .await\n    .map_err(AppResponseError::from)?;\n  let resp = SignInTokenResponse { is_new };\n  Ok(AppResponse::Ok().with_data(resp).into())\n}\n\n#[tracing::instrument(skip(state), err)]\nasync fn get_user_profile_handler(\n  uuid: UserUuid,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<AFUserProfile>> {\n  let profile = get_profile(&state.pg_pool, &uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  Ok(AppResponse::Ok().with_data(profile).into())\n}\n\n#[tracing::instrument(skip(state), err)]\nasync fn get_user_workspace_info_handler(\n  uuid: UserUuid,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<JsonAppResponse<AFUserWorkspaceInfo>> {\n  let app_version = client_version_from_headers(req.headers())\n    .ok()\n    .and_then(|s| Version::parse(s).ok());\n  let exclude_guest = app_version\n    .map(|s| s < Version::new(0, 9, 4))\n    .unwrap_or(true);\n\n  let info = get_user_workspace_info(&state.pg_pool, &uuid, exclude_guest).await?;\n  Ok(AppResponse::Ok().with_data(info).into())\n}\n\n#[tracing::instrument(skip(state, auth, payload), err)]\nasync fn update_user_handler(\n  auth: Authorization,\n  payload: Json<UpdateUserParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let params = payload.into_inner();\n  update_user(&state.pg_pool, auth.uuid()?, params).await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[tracing::instrument(skip(state), err)]\nasync fn delete_user_handler(\n  auth: Authorization,\n  state: Data<AppState>,\n  query: web::Query<DeleteUserQuery>,\n) -> Result<JsonAppResponse<()>, actix_web::Error> {\n  let user_uuid = auth.uuid()?;\n  let DeleteUserQuery {\n    provider_access_token,\n    provider_refresh_token,\n  } = query.into_inner();\n  delete_user(\n    &state.pg_pool,\n    &state.redis_connection_manager,\n    &state.bucket_storage,\n    &state.gotrue_client,\n    &state.gotrue_admin,\n    &state.config.apple_oauth,\n    auth,\n    user_uuid,\n    provider_access_token,\n    provider_refresh_token,\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[derive(MultipartForm)]\n#[multipart(duplicate_field = \"deny\")]\nstruct UploadUserImageAssetForm {\n  #[multipart(limit = \"1MB\")]\n  asset: Bytes,\n}\n\nasync fn get_user_image_asset_handler(\n  _user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  state: Data<AppState>,\n) -> Result<HttpResponse> {\n  let (person_id, file_id) = path.into_inner();\n  let avatar = get_user_image_asset(state.bucket_client.clone(), &person_id, file_id).await?;\n  Ok(\n    HttpResponse::build(StatusCode::OK)\n      .content_type(avatar.content_type)\n      .body(avatar.data),\n  )\n}\n\nasync fn post_user_image_asset_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  MultipartForm(form): MultipartForm<UploadUserImageAssetForm>,\n) -> Result<JsonAppResponse<UserImageAssetSource>> {\n  let file_id =\n    upload_user_image_asset(state.bucket_client.clone(), &form.asset, &user_uuid).await?;\n\n  Ok(\n    AppResponse::Ok()\n      .with_data(UserImageAssetSource { file_id })\n      .into(),\n  )\n}\n"
  },
  {
    "path": "src/api/util.rs",
    "content": "use crate::domain::compression::{CompressionType, X_COMPRESSION_BUFFER_SIZE, X_COMPRESSION_TYPE};\nuse actix_http::header::HeaderMap;\nuse actix_web::web::Payload;\nuse app_error::AppError;\n\nuse actix_web::HttpRequest;\nuse async_trait::async_trait;\nuse byteorder::{ByteOrder, LittleEndian};\nuse chrono::Utc;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_protocol::validate_encode_collab;\nuse database_entity::dto::CollabParams;\nuse std::str::FromStr;\nuse tokio_stream::StreamExt;\nuse uuid::Uuid;\n\n#[inline]\npub fn compress_type_from_header_value(headers: &HeaderMap) -> Result<CompressionType, AppError> {\n  let compression_type_str = headers\n    .get(X_COMPRESSION_TYPE)\n    .ok_or(AppError::InvalidRequest(\n      \"Missing X-Compression-Type header\".to_string(),\n    ))?\n    .to_str()\n    .map_err(|err| {\n      AppError::InvalidRequest(format!(\"Failed to parse X-Compression-Type: {}\", err))\n    })?;\n  let buffer_size_str = headers\n    .get(X_COMPRESSION_BUFFER_SIZE)\n    .ok_or_else(|| {\n      AppError::InvalidRequest(\"Missing X-Compression-Buffer-Size header\".to_string())\n    })?\n    .to_str()\n    .map_err(|err| {\n      AppError::InvalidRequest(format!(\n        \"Failed to parse X-Compression-Buffer-Size: {}\",\n        err\n      ))\n    })?;\n\n  let buffer_size = usize::from_str(buffer_size_str).map_err(|err| {\n    AppError::InvalidRequest(format!(\n      \"X-Compression-Buffer-Size is not a valid usize: {}\",\n      err\n    ))\n  })?;\n\n  match compression_type_str {\n    \"brotli\" => Ok(CompressionType::Brotli { buffer_size }),\n    s => Err(AppError::InvalidRequest(format!(\n      \"Unknown compression type: {}\",\n      s\n    ))),\n  }\n}\n\nfn value_from_headers<'a>(\n  headers: &'a HeaderMap,\n  keys: &[&str],\n  missing_msg: &str,\n) -> Result<&'a str, AppError> {\n  keys\n    .iter()\n    .find_map(|key| headers.get(*key))\n    .ok_or_else(|| AppError::InvalidRequest(missing_msg.to_string()))\n    .and_then(|header| {\n      header\n        .to_str()\n        .map_err(|err| AppError::InvalidRequest(format!(\"Failed to parse header: {}\", err)))\n    })\n}\n\n/// Retrieve client version from headers\npub fn client_version_from_headers(headers: &HeaderMap) -> Result<&str, AppError> {\n  value_from_headers(\n    headers,\n    &[\"Client-Version\", \"client-version\", \"client_version\"],\n    \"Missing Client-Version or client-version header\",\n  )\n}\n\n/// Retrieve device ID from headers\npub fn device_id_from_headers(headers: &HeaderMap) -> Result<&str, AppError> {\n  value_from_headers(\n    headers,\n    &[\"Device-Id\", \"device-id\", \"device_id\", \"Device-ID\"],\n    \"Missing Device-Id or device_id header\",\n  )\n}\n\n/// Create new realtime user for requests from appflowy web\npub fn realtime_user_for_web_request(\n  headers: &HeaderMap,\n  uid: i64,\n) -> Result<RealtimeUser, AppError> {\n  let app_version = client_version_from_headers(headers)\n    .map(|s| s.to_string())\n    .unwrap_or_else(|_| \"web\".to_string());\n  let device_id = device_id_from_headers(headers)\n    .map(|s| s.to_string())\n    .unwrap_or_else(|_| Uuid::new_v4().to_string());\n  let session_id = device_id.clone();\n  let user = RealtimeUser {\n    uid,\n    device_id,\n    connect_at: Utc::now().timestamp(),\n    session_id,\n    app_version,\n  };\n  Ok(user)\n}\n\n#[async_trait]\npub trait CollabValidator {\n  async fn check_encode_collab(&self) -> Result<(), AppError>;\n}\n\n#[async_trait]\nimpl CollabValidator for CollabParams {\n  async fn check_encode_collab(&self) -> Result<(), AppError> {\n    validate_encode_collab(&self.object_id, &self.encoded_collab_v1, &self.collab_type)\n      .await\n      .map_err(|err| AppError::NoRequiredData(err.to_string()))\n  }\n}\n\npub struct PayloadReader {\n  payload: Payload,\n  buffer: Vec<u8>,\n  buf_start: usize,\n  buf_end: usize,\n}\n\nimpl PayloadReader {\n  pub fn new(payload: Payload) -> Self {\n    Self {\n      payload,\n      buffer: Vec::new(),\n      buf_start: 0,\n      buf_end: 0,\n    }\n  }\n\n  pub async fn read_exact(&mut self, dest: &mut [u8]) -> actix_web::Result<()> {\n    let mut written = 0;\n    while written < dest.len() {\n      if self.buf_start == self.buf_end {\n        self.fill_buffer().await?;\n      }\n\n      let current_dest = &mut dest[written..];\n      let current_src = &self.buffer[self.buf_start..self.buf_end];\n      let n = copy_buffer(current_src, current_dest);\n      written += n;\n      self.buf_start += n;\n    }\n    Ok(())\n  }\n\n  pub async fn read_u32_little_endian(&mut self) -> actix_web::Result<u32> {\n    self.fill_at_least(4).await?;\n\n    let bytes: [u8; 4] = [\n      self.buffer[self.buf_start],\n      self.buffer[self.buf_start + 1],\n      self.buffer[self.buf_start + 2],\n      self.buffer[self.buf_start + 3],\n    ];\n    self.buf_start += 4;\n\n    Ok(LittleEndian::read_u32(&bytes))\n  }\n\n  async fn fill_at_least(&mut self, min_len: usize) -> actix_web::Result<()> {\n    while self.len() < min_len {\n      let n = self.fill_buffer().await?;\n      if n == 0 {\n        return Err(AppError::InvalidRequest(\"unexpected EOF\".to_string()).into());\n      }\n    }\n    Ok(())\n  }\n\n  fn len(&self) -> usize {\n    self.buf_end - self.buf_start\n  }\n\n  async fn fill_buffer(&mut self) -> actix_web::Result<usize> {\n    if self.buf_start == self.buf_end {\n      self.buffer.clear();\n      self.buf_start = 0;\n      self.buf_end = 0;\n    }\n\n    let bytes = self.payload.try_next().await?;\n    match bytes {\n      Some(bytes) => {\n        self.buffer.extend_from_slice(&bytes);\n        self.buf_end += bytes.len();\n        Ok(bytes.len())\n      },\n      None => Ok(0),\n    }\n  }\n}\n\nfn copy_buffer(src: &[u8], dest: &mut [u8]) -> usize {\n  let bytes_to_copy = std::cmp::min(src.len(), dest.len());\n  dest[..bytes_to_copy].copy_from_slice(&src[..bytes_to_copy]);\n  bytes_to_copy\n}\n\n#[inline]\npub(crate) fn ai_model_from_header(req: &HttpRequest) -> &str {\n  req\n    .headers()\n    .get(\"ai-model\")\n    .and_then(|header| header.to_str().ok())\n    .unwrap_or(\"Default\")\n}\n\n#[cfg(test)]\nmod tests {\n  use super::*;\n  use actix_http::header::{HeaderMap, HeaderName, HeaderValue};\n\n  fn setup_headers(key: &str, value: &str) -> HeaderMap {\n    let mut headers = HeaderMap::new();\n    headers.insert(\n      HeaderName::from_str(key).unwrap(),\n      HeaderValue::from_str(value).unwrap(),\n    );\n    headers\n  }\n\n  #[test]\n  fn test_client_version_valid_variations() {\n    let test_cases = [\n      (\"Client-Version\", \"1.0.0\"),\n      (\"client-version\", \"2.0.0\"),\n      (\"client_version\", \"3.0.0\"),\n    ];\n\n    for (key, value) in test_cases.iter() {\n      let headers = setup_headers(key, value);\n      let result = client_version_from_headers(&headers);\n      assert!(result.is_ok());\n      assert_eq!(result.unwrap(), *value);\n    }\n  }\n\n  #[test]\n  fn test_device_id_valid_variations() {\n    let test_cases = [\n      (\"Device-Id\", \"device123\"),\n      (\"device-id\", \"device456\"),\n      (\"device_id\", \"device789\"),\n      (\"Device-ID\", \"device000\"),\n    ];\n\n    for (key, value) in test_cases.iter() {\n      let headers = setup_headers(key, value);\n      let result = device_id_from_headers(&headers);\n      assert!(result.is_ok());\n      assert_eq!(result.unwrap(), *value);\n    }\n  }\n\n  #[test]\n  fn test_missing_client_version() {\n    let headers = HeaderMap::new();\n    let result = client_version_from_headers(&headers);\n    assert!(result.is_err());\n    match result {\n      Err(AppError::InvalidRequest(msg)) => {\n        assert_eq!(msg, \"Missing Client-Version or client-version header\");\n      },\n      _ => panic!(\"Expected InvalidRequest error\"),\n    }\n  }\n\n  #[test]\n  fn test_missing_device_id() {\n    let headers = HeaderMap::new();\n    let result = device_id_from_headers(&headers);\n    assert!(result.is_err());\n    match result {\n      Err(AppError::InvalidRequest(msg)) => {\n        assert_eq!(msg, \"Missing Device-Id or device_id header\");\n      },\n      _ => panic!(\"Expected InvalidRequest error\"),\n    }\n  }\n\n  #[test]\n  fn test_invalid_header_value() {\n    let mut headers = HeaderMap::new();\n    // Create an invalid UTF-8 header value\n    headers.insert(\n      HeaderName::from_str(\"Client-Version\").unwrap(),\n      HeaderValue::from_bytes(&[0xFF, 0xFF]).unwrap(),\n    );\n\n    let result = client_version_from_headers(&headers);\n    assert!(result.is_err());\n    match result {\n      Err(AppError::InvalidRequest(msg)) => {\n        assert!(msg.starts_with(\"Failed to parse header:\"));\n      },\n      _ => panic!(\"Expected InvalidRequest error\"),\n    }\n  }\n\n  #[test]\n  fn test_value_from_headers_multiple_keys_present() {\n    let mut headers = HeaderMap::new();\n    headers.insert(\n      HeaderName::from_str(\"key1\").unwrap(),\n      HeaderValue::from_static(\"value1\"),\n    );\n    headers.insert(\n      HeaderName::from_str(\"key2\").unwrap(),\n      HeaderValue::from_static(\"value2\"),\n    );\n\n    let result = value_from_headers(&headers, &[\"key1\", \"key2\"], \"Missing key\");\n    assert!(result.is_ok());\n    // Should return the first matching key's value\n    assert_eq!(result.unwrap(), \"value1\");\n  }\n}\n"
  },
  {
    "path": "src/api/workspace.rs",
    "content": "use crate::api::util::{client_version_from_headers, realtime_user_for_web_request, PayloadReader};\nuse crate::api::util::{compress_type_from_header_value, device_id_from_headers};\nuse crate::api::ws::RealtimeServerAddr;\nuse crate::biz;\nuse crate::biz::authentication::jwt::{Authorization, OptionalUserUuid, UserUuid};\nuse crate::biz::collab::database::check_if_row_document_collab_exists;\nuse crate::biz::collab::ops::{\n  get_user_favorite_folder_views, get_user_recent_folder_views, get_user_trash_folder_views,\n};\nuse crate::biz::collab::utils::{collab_from_doc_state, DUMMY_UID};\nuse crate::biz::workspace;\nuse crate::biz::workspace::duplicate::duplicate_view_tree_and_collab;\nuse crate::biz::workspace::invite::{\n  delete_workspace_invite_code, generate_workspace_invite_token, get_invite_code_for_workspace,\n  join_workspace_invite_by_code,\n};\nuse crate::biz::workspace::ops::{\n  create_comment_on_published_view, create_reaction_on_comment, get_comments_on_published_view,\n  get_reactions_on_published_view, get_workspace_owner, remove_comment_on_published_view,\n  remove_reaction_on_comment, update_workspace_member_profile,\n};\nuse crate::biz::workspace::page_view::{\n  add_recent_pages, append_block_at_the_end_of_page, create_database_view, create_folder_view,\n  create_orphaned_view, create_page, create_space, delete_all_pages_from_trash, delete_trash,\n  favorite_page, get_page_view_collab, move_page, move_page_to_trash, publish_page,\n  reorder_favorite_page, restore_all_pages_from_trash, restore_page_from_trash, unpublish_page,\n  update_page, update_page_collab_data, update_page_extra, update_page_icon, update_page_name,\n  update_space,\n};\nuse crate::biz::workspace::publish::get_workspace_default_publish_view_info_meta;\nuse crate::biz::workspace::quick_note::{\n  create_quick_note, delete_quick_note, list_quick_notes, update_quick_note,\n};\nuse crate::domain::compression::{\n  blocking_decompress, decompress, CompressionType, X_COMPRESSION_TYPE,\n};\nuse crate::state::AppState;\nuse access_control::act::Action;\nuse actix_web::web::{Bytes, Path, Payload};\nuse actix_web::web::{Data, Json, PayloadConfig};\nuse actix_web::{web, HttpResponse, ResponseError, Scope};\nuse actix_web::{HttpRequest, Result};\nuse anyhow::{anyhow, Context};\nuse app_error::{AppError, ErrorCode};\nuse appflowy_collaborate::actix_ws::entities::{\n  ClientGenerateEmbeddingMessage, ClientHttpStreamMessage, ClientHttpUpdateMessage,\n};\n\nuse bytes::BytesMut;\nuse chrono::{DateTime, Duration, Utc};\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab::preclude::Collab;\nuse collab_database::entity::FieldType;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::timestamp;\nuse collab_rt_entity::collab_proto::{CollabDocStateParams, PayloadCompressionType};\nuse collab_rt_entity::realtime_proto::HttpRealtimeMessage;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::RealtimeMessage;\nuse collab_rt_protocol::collab_from_encode_collab;\nuse database::user::select_uid_from_email;\nuse database_entity::dto::PublishCollabItem;\nuse database_entity::dto::PublishInfo;\nuse database_entity::dto::*;\nuse indexer::scheduler::{UnindexedCollabTask, UnindexedData};\nuse itertools::Itertools;\nuse prost::Message as ProstMessage;\nuse rayon::prelude::*;\n\nuse semver::Version;\nuse sha2::{Digest, Sha256};\nuse shared_entity::dto::publish_dto::DuplicatePublishedPageResponse;\nuse shared_entity::dto::workspace_dto::*;\nuse shared_entity::response::AppResponseError;\nuse shared_entity::response::{AppResponse, JsonAppResponse};\nuse sqlx::types::uuid;\nuse std::io::Cursor;\nuse std::time::Instant;\nuse tokio_stream::StreamExt;\nuse tokio_tungstenite::tungstenite::Message;\nuse tracing::{error, event, instrument, trace};\nuse uuid::Uuid;\nuse validator::Validate;\nuse workspace_template::document::parser::SerdeBlock;\n\npub const WORKSPACE_ID_PATH: &str = \"workspace_id\";\npub const COLLAB_OBJECT_ID_PATH: &str = \"object_id\";\n\npub const WORKSPACE_PATTERN: &str = \"/api/workspace\";\npub const WORKSPACE_MEMBER_PATTERN: &str = \"/api/workspace/{workspace_id}/member\";\npub const WORKSPACE_INVITE_PATTERN: &str = \"/api/workspace/{workspace_id}/invite\";\npub const COLLAB_PATTERN: &str = \"/api/workspace/{workspace_id}/collab/{object_id}\";\npub const V1_COLLAB_PATTERN: &str = \"/api/workspace/v1/{workspace_id}/collab/{object_id}\";\npub const WORKSPACE_PUBLISH_PATTERN: &str = \"/api/workspace/{workspace_id}/publish\";\npub const WORKSPACE_PUBLISH_NAMESPACE_PATTERN: &str =\n  \"/api/workspace/{workspace_id}/publish-namespace\";\n\npub fn workspace_scope() -> Scope {\n  web::scope(\"/api/workspace\")\n    .service(\n      web::resource(\"\")\n        .route(web::get().to(list_workspace_handler))\n        .route(web::post().to(create_workspace_handler))\n        .route(web::patch().to(patch_workspace_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/invite\").route(web::post().to(post_workspace_invite_handler)), // invite members to workspace\n    )\n    .service(\n      web::resource(\"/invite\").route(web::get().to(get_workspace_invite_handler)), // show invites for user\n    )\n    .service(\n      web::resource(\"/invite/{invite_id}\").route(web::get().to(get_workspace_invite_by_id_handler)),\n    )\n    .service(\n      web::resource(\"/accept-invite/{invite_id}\")\n        .route(web::post().to(post_accept_workspace_invite_handler)), // accept invitation to workspace\n    )\n    .service(\n      web::resource(\"/join-by-invite-code\")\n        .route(web::post().to(post_join_workspace_invite_by_code_handler)),\n    )\n\n    .service(web::resource(\"/{workspace_id}\").route(web::delete().to(delete_workspace_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/settings\")\n        .route(web::get().to(get_workspace_settings_handler))\n        .route(web::post().to(post_workspace_settings_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/open\").route(web::put().to(open_workspace_handler)))\n    .service(web::resource(\"/{workspace_id}/leave\").route(web::post().to(leave_workspace_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/member\")\n        .route(web::get().to(get_workspace_members_handler))\n        .route(web::put().to(update_workspace_member_handler))\n        .route(web::delete().to(remove_workspace_member_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/mentionable-person\")\n        .route(web::get().to(list_workspace_mentionable_person_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/mentionable-person/{contact_id}\")\n        .route(web::get().to(get_workspace_mentionable_person_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/update-member-profile\")\n        .route(web::put().to(put_workspace_member_profile_handler)),\n    )\n    // Deprecated since v0.9.24\n    .service(\n      web::resource(\"/{workspace_id}/member/user/{user_id}\")\n        .route(web::get().to(get_workspace_member_handler)),\n    )\n    .service(\n      web::resource(\"v1/{workspace_id}/member/user/{user_id}\")\n        .route(web::get().to(get_workspace_member_v1_handler)),\n      )\n    .service(\n      web::resource(\"/{workspace_id}/collab/{object_id}\")\n        .app_data(\n          PayloadConfig::new(5 * 1024 * 1024), // 5 MB\n        )\n        .route(web::post().to(create_collab_handler))\n        .route(web::get().to(get_collab_handler))\n        .route(web::put().to(update_collab_handler))\n        .route(web::delete().to(delete_collab_handler)),\n    )\n    .service(\n      web::resource(\"/v1/{workspace_id}/collab/{object_id}\")\n        .route(web::get().to(v1_get_collab_handler)),\n    )\n    .service(\n      web::resource(\"/v1/{workspace_id}/collab/{object_id}/json\")\n        .route(web::get().to(get_collab_json_handler)),\n    )\n    .service(\n      web::resource(\"/v1/{workspace_id}/collab/{object_id}/full-sync\")\n        .route(web::post().to(collab_full_sync_handler)),\n    )\n    .service(\n      web::resource(\"/v1/{workspace_id}/collab/{object_id}/web-update\")\n        .route(web::post().to(post_web_update_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/collab/{object_id}/row-document-collab-exists\")\n          .route(web::get().to(get_row_document_collab_exists_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/collab/{object_id}/embed-info\")\n        .route(web::get().to(get_collab_embed_info_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/collab/{object_id}/generate-embedding\")\n          .route(web::get().to(force_generate_collab_embedding_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/collab/embed-info/list\")\n        .route(web::post().to(batch_get_collab_embed_info_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/space\").route(web::post().to(post_space_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/space/{view_id}\").route(web::patch().to(update_space_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/folder-view\").route(web::post().to(post_folder_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view\").route(web::post().to(post_page_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}\")\n        .route(web::get().to(get_page_view_handler))\n        .route(web::patch().to(update_page_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/mentionable-person-with-access\")\n        .route(web::get().to(list_page_mentionable_person_with_access_handler))\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/page-mention\")\n        .route(web::put().to(put_page_mention_handler))\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/update-name\")\n        .route(web::post().to(update_page_name_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/update-icon\")\n        .route(web::post().to(update_page_icon_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/update-extra\")\n        .route(web::post().to(update_page_extra_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/remove-icon\")\n        .route(web::post().to(remove_page_icon_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/favorite\")\n        .route(web::post().to(favorite_page_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/append-block\")\n        .route(web::post().to(append_block_to_page_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/move\")\n        .route(web::post().to(move_page_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/reorder-favorite\")\n        .route(web::post().to(reorder_favorite_page_handler)),\n    )\n    .service(\n          web::resource(\"/{workspace_id}/page-view/{view_id}/duplicate\")\n            .route(web::post().to(duplicate_page_handler)),\n        )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/database-view\")\n        .route(web::post().to(post_page_database_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/move-to-trash\")\n        .route(web::post().to(move_page_to_trash_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/restore-from-trash\")\n        .route(web::post().to(restore_page_from_trash_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/add-recent-pages\")\n        .route(web::post().to(add_recent_pages_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/restore-all-pages-from-trash\")\n        .route(web::post().to(restore_all_pages_from_trash_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/delete-all-pages-from-trash\")\n        .route(web::post().to(delete_all_pages_from_trash_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/publish\")\n        .route(web::post().to(publish_page_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/page-view/{view_id}/unpublish\")\n        .route(web::post().to(unpublish_page_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/orphaned-view\")\n        .route(web::post().to(post_orphaned_view_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/batch/collab\")\n        .route(web::post().to(batch_create_collab_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/usage\").route(web::get().to(get_workspace_usage_handler)),\n    )\n    .service(\n      web::resource(\"/published/{publish_namespace}\")\n        .route(web::get().to(get_default_published_collab_info_meta_handler)),\n    )\n    .service(\n      web::resource(\"/v1/published/{publish_namespace}/{publish_name}\")\n        .route(web::get().to(get_v1_published_collab_handler)),\n    )\n    .service(\n      web::resource(\"/published/{publish_namespace}/{publish_name}/blob\")\n        .route(web::get().to(get_published_collab_blob_handler)),\n    )\n    .service(\n      web::resource(\"{workspace_id}/published-duplicate\")\n        .route(web::post().to(post_published_duplicate_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/published-info\")\n        .route(web::get().to(list_published_collab_info_handler)),\n    )\n    .service(\n      // deprecated since 0.7.4\n      web::resource(\"/published-info/{view_id}\")\n        .route(web::get().to(get_published_collab_info_handler)),\n    )\n    .service(\n      web::resource(\"/v1/published-info/{view_id}\")\n        .route(web::get().to(get_v1_published_collab_info_handler)),\n    )\n    .service(\n      web::resource(\"/published-info/{view_id}/comment\")\n        .route(web::get().to(get_published_collab_comment_handler))\n        .route(web::post().to(post_published_collab_comment_handler))\n        .route(web::delete().to(delete_published_collab_comment_handler)),\n    )\n    .service(\n      web::resource(\"/published-info/{view_id}/reaction\")\n        .route(web::get().to(get_published_collab_reaction_handler))\n        .route(web::post().to(post_published_collab_reaction_handler))\n        .route(web::delete().to(delete_published_collab_reaction_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/publish-namespace\")\n        .route(web::put().to(put_publish_namespace_handler))\n        .route(web::get().to(get_publish_namespace_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/publish-default\")\n        .route(web::put().to(put_workspace_default_published_view_handler))\n        .route(web::delete().to(delete_workspace_default_published_view_handler))\n        .route(web::get().to(get_workspace_published_default_info_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/publish\")\n        .route(web::post().to(post_publish_collabs_handler))\n        .route(web::delete().to(delete_published_collabs_handler))\n        .route(web::patch().to(patch_published_collabs_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/folder\").route(web::get().to(get_workspace_folder_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/recent\").route(web::get().to(get_recent_views_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/favorite\").route(web::get().to(get_favorite_views_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/trash\").route(web::get().to(get_trash_views_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/trash/{view_id}\")\n        .route(web::delete().to(delete_page_from_trash_handler)),\n    )\n    .service(\n      web::resource(\"/published-outline/{publish_namespace}\")\n        .route(web::get().to(get_workspace_publish_outline_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/collab_list\")\n      .route(web::get().to(batch_get_collab_handler))\n      // Web browser can't carry payload when using GET method, so for browser compatibility, we use POST method\n      .route(web::post().to(batch_get_collab_handler)),\n    )\n    .service(web::resource(\"/{workspace_id}/database\").route(web::get().to(list_database_handler)))\n    .service(\n      web::resource(\"/{workspace_id}/database/{database_id}/row\")\n        .route(web::get().to(list_database_row_id_handler))\n        .route(web::post().to(post_database_row_handler))\n        .route(web::put().to(put_database_row_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/database/{database_id}/fields\")\n        .route(web::get().to(get_database_fields_handler))\n        .route(web::post().to(post_database_fields_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/database/{database_id}/row/updated\")\n        .route(web::get().to(list_database_row_id_updated_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/database/{database_id}/row/detail\")\n        .route(web::get().to(list_database_row_details_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/quick-note\")\n        .route(web::get().to(list_quick_notes_handler))\n        .route(web::post().to(post_quick_note_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/quick-note/{quick_note_id}\")\n        .route(web::put().to(update_quick_note_handler))\n        .route(web::delete().to(delete_quick_note_handler)),\n    )\n    .service(\n      web::resource(\"/{workspace_id}/invite-code\")\n        .route(web::get().to(get_workspace_invite_code_handler))\n        .route(web::delete().to(delete_workspace_invite_code_handler))\n        .route(web::post().to(post_workspace_invite_code_handler)),\n    )\n}\n\npub fn collab_scope() -> Scope {\n  web::scope(\"/api/realtime\").service(\n    web::resource(\"post/stream\")\n      .app_data(\n        PayloadConfig::new(10 * 1024 * 1024), // 10 MB\n      )\n      .route(web::post().to(post_realtime_message_stream_handler)),\n  )\n}\n\n// Adds a workspace for user, if success, return the workspace id\n#[instrument(skip_all, err)]\nasync fn create_workspace_handler(\n  uuid: UserUuid,\n  state: Data<AppState>,\n  create_workspace_param: Json<CreateWorkspaceParam>,\n) -> Result<Json<AppResponse<AFWorkspace>>> {\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  let create_workspace_param = create_workspace_param.into_inner();\n  let workspace_name = create_workspace_param\n    .workspace_name\n    .unwrap_or_else(|| format!(\"workspace_{}\", chrono::Utc::now().timestamp()));\n\n  let workspace_icon = create_workspace_param.workspace_icon.unwrap_or_default();\n  let new_workspace = workspace::ops::create_workspace_for_user(\n    &state.pg_pool,\n    state.workspace_access_control.clone(),\n    &state.collab_storage,\n    &state.metrics.collab_metrics,\n    &uuid,\n    uid,\n    &workspace_name,\n    &workspace_icon,\n  )\n  .await?;\n\n  Ok(AppResponse::Ok().with_data(new_workspace).into())\n}\n\n// Edit existing workspace\n#[instrument(skip_all, err)]\nasync fn patch_workspace_handler(\n  uuid: UserUuid,\n  state: Data<AppState>,\n  params: Json<PatchWorkspaceParam>,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &params.workspace_id, Action::Write)\n    .await?;\n  let params = params.into_inner();\n  workspace::ops::patch_workspace(\n    &state.pg_pool,\n    &params.workspace_id,\n    params.workspace_name.as_deref(),\n    params.workspace_icon.as_deref(),\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn delete_workspace_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Delete)\n    .await?;\n  workspace::ops::delete_workspace_for_user(\n    state.pg_pool.clone(),\n    state.redis_connection_manager.clone(),\n    workspace_id,\n    state.bucket_storage.clone(),\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\n/// Get all user owned and shared workspaces\n#[instrument(skip_all, err)]\nasync fn list_workspace_handler(\n  uuid: UserUuid,\n  state: Data<AppState>,\n  query: web::Query<QueryWorkspaceParam>,\n  req: HttpRequest,\n) -> Result<JsonAppResponse<Vec<AFWorkspace>>> {\n  let app_version = client_version_from_headers(req.headers())\n    .ok()\n    .and_then(|s| Version::parse(s).ok());\n  let exclude_guest = app_version\n    .map(|s| s < Version::new(0, 9, 4))\n    .unwrap_or(true);\n  let QueryWorkspaceParam {\n    include_member_count,\n    include_role,\n  } = query.into_inner();\n\n  let workspaces = workspace::ops::get_all_user_workspaces(\n    &state.pg_pool,\n    &uuid,\n    include_member_count.unwrap_or(false),\n    include_role.unwrap_or(false),\n    exclude_guest,\n  )\n  .await?;\n  Ok(AppResponse::Ok().with_data(workspaces).into())\n}\n\n#[instrument(skip(payload, state), err)]\nasync fn post_workspace_invite_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  payload: Json<Vec<WorkspaceMemberInvitation>>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n\n  let invitations = payload.into_inner();\n  workspace::ops::invite_workspace_members(\n    &state.mailer,\n    &state.pg_pool,\n    &user_uuid,\n    &workspace_id,\n    invitations,\n    &state.config.appflowy_web_url,\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn get_workspace_invite_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  query: web::Query<WorkspaceInviteQuery>,\n) -> Result<JsonAppResponse<Vec<AFWorkspaceInvitation>>> {\n  let query = query.into_inner();\n  let res =\n    workspace::ops::list_workspace_invitations_for_user(&state.pg_pool, &user_uuid, query.status)\n      .await?;\n  Ok(AppResponse::Ok().with_data(res).into())\n}\n\nasync fn get_workspace_invite_by_id_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  invite_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<AFWorkspaceInvitation>> {\n  let invite_id = invite_id.into_inner();\n  let res =\n    workspace::ops::get_workspace_invitations_for_user(&state.pg_pool, &user_uuid, &invite_id)\n      .await?;\n  Ok(AppResponse::Ok().with_data(res).into())\n}\n\nasync fn post_accept_workspace_invite_handler(\n  auth: Authorization,\n  invite_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let user_uuid = auth.uuid()?;\n  let user_uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let invite_id = invite_id.into_inner();\n  workspace::ops::accept_workspace_invite(\n    &state.pg_pool,\n    state.workspace_access_control.clone(),\n    user_uid,\n    &user_uuid,\n    &invite_id,\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn post_join_workspace_invite_by_code_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  payload: Json<JoinWorkspaceByInviteCodeParams>,\n) -> Result<JsonAppResponse<InvitedWorkspace>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let invited_workspace_id =\n    join_workspace_invite_by_code(&state.pg_pool, &payload.code, uid).await?;\n  state\n    .workspace_access_control\n    .insert_role(&uid, &invited_workspace_id, AFRole::Member)\n    .await?;\n  Ok(\n    AppResponse::Ok()\n      .with_data(InvitedWorkspace {\n        workspace_id: invited_workspace_id,\n      })\n      .into(),\n  )\n}\n\n#[instrument(level = \"trace\", skip_all, err, fields(user_uuid))]\nasync fn get_workspace_settings_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<AFWorkspaceSettings>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let settings = workspace::ops::get_workspace_settings(&state.pg_pool, &workspace_id).await?;\n  Ok(AppResponse::Ok().with_data(settings).into())\n}\n\n#[instrument(level = \"info\", skip_all, err, fields(user_uuid))]\nasync fn post_workspace_settings_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n  data: Json<AFWorkspaceSettingsChange>,\n) -> Result<JsonAppResponse<AFWorkspaceSettings>> {\n  let data = data.into_inner();\n  trace!(\"workspace settings: {:?}\", data);\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  let settings =\n    workspace::ops::update_workspace_settings(&state.pg_pool, &workspace_id, data).await?;\n  Ok(AppResponse::Ok().with_data(settings).into())\n}\n\n/// A workspace member/owner can view all members of the workspace, except for guests.\n/// A guest can only view their own information.\n#[instrument(skip_all, err)]\nasync fn get_workspace_members_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<Vec<AFWorkspaceMember>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  let requester_member_info =\n    workspace::ops::get_workspace_member(uid, &state.pg_pool, &workspace_id).await?;\n  let members: Vec<AFWorkspaceMember> = if requester_member_info.role == AFRole::Guest {\n    let owner = get_workspace_owner(&state.pg_pool, &workspace_id).await?;\n    vec![requester_member_info.into(), owner.into()]\n  } else {\n    workspace::ops::get_workspace_members_exclude_guest(&state.pg_pool, &workspace_id)\n      .await?\n      .into_iter()\n      .map(|member| member.into())\n      .collect()\n  };\n\n  Ok(AppResponse::Ok().with_data(members).into())\n}\n\n#[instrument(skip_all, err)]\nasync fn remove_workspace_member_handler(\n  user_uuid: UserUuid,\n  payload: Json<WorkspaceMembers>,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<()>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n\n  let member_emails = payload\n    .into_inner()\n    .0\n    .into_iter()\n    .map(|member| member.0)\n    .collect::<Vec<String>>();\n  workspace::ops::remove_workspace_members(\n    &state.pg_pool,\n    &workspace_id,\n    &member_emails,\n    state.workspace_access_control.clone(),\n  )\n  .await?;\n\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(skip_all, err)]\nasync fn list_workspace_mentionable_person_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<Uuid>,\n) -> Result<JsonAppResponse<MentionablePersons>> {\n  let workspace_id = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  // Guest can access mentionable users, but only themselves and the owner\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  let persons = workspace::ops::list_workspace_mentionable_persons_with_last_mentioned_time(\n    &state.pg_pool,\n    &workspace_id,\n    uid,\n    &user_uuid,\n  )\n  .await?;\n  Ok(\n    AppResponse::Ok()\n      .with_data(MentionablePersons { persons })\n      .into(),\n  )\n}\n\n#[instrument(skip_all, err)]\nasync fn list_page_mentionable_person_with_access_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<(Uuid, Uuid)>,\n) -> Result<JsonAppResponse<MentionablePersonsWithAccess>> {\n  let (workspace_id, view_id) = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  let persons = workspace::page_view::list_page_mentionable_persons_with_access(\n    &state.ws_server,\n    &state.pg_pool,\n    &workspace_id,\n    &view_id,\n  )\n  .await?;\n  Ok(\n    AppResponse::Ok()\n      .with_data(MentionablePersonsWithAccess { persons })\n      .into(),\n  )\n}\n\n#[instrument(skip_all, err)]\nasync fn put_page_mention_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  payload: Json<PageMentionUpdate>,\n) -> Result<JsonAppResponse<()>> {\n  let (workspace_id, view_id) = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  workspace::page_view::update_page_mention(\n    &state.pg_pool,\n    &workspace_id,\n    &view_id,\n    uid,\n    &payload.into_inner(),\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(skip_all, err)]\nasync fn get_workspace_mentionable_person_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<(Uuid, Uuid)>,\n) -> Result<JsonAppResponse<MentionablePerson>> {\n  let (workspace_id, person_id) = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  let person =\n    workspace::ops::get_workspace_mentionable_person(&state.pg_pool, &workspace_id, &person_id)\n      .await?;\n  Ok(AppResponse::Ok().with_data(person).into())\n}\n\nasync fn put_workspace_member_profile_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<WorkspaceMemberProfile>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let workspace_id = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Guest)\n    .await?;\n  let updated_profile = payload.into_inner();\n  update_workspace_member_profile(&state.pg_pool, &workspace_id, uid, &updated_profile).await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(skip_all, err)]\nasync fn get_workspace_member_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<(Uuid, i64)>,\n) -> Result<JsonAppResponse<AFWorkspaceMember>> {\n  let (workspace_id, member_uid) = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  // Guest users can not get workspace members\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let member_row = workspace::ops::get_workspace_member(member_uid, &state.pg_pool, &workspace_id)\n    .await\n    .map_err(|_| {\n      AppResponseError::new(\n        ErrorCode::MemberNotFound,\n        format!(\n          \"requested member uid {} is not present in workspace {}\",\n          member_uid, workspace_id\n        ),\n      )\n    })?;\n  let member = AFWorkspaceMember {\n    name: member_row.name,\n    email: member_row.email,\n    role: member_row.role,\n    avatar_url: member_row.avatar_url,\n    joined_at: member_row.created_at,\n  };\n\n  Ok(AppResponse::Ok().with_data(member).into())\n}\n\n// This use user uuid as opposed to uid\n#[instrument(skip_all, err)]\nasync fn get_workspace_member_v1_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  path: web::Path<(Uuid, Uuid)>,\n) -> Result<JsonAppResponse<AFWorkspaceMember>> {\n  let (workspace_id, member_uuid) = path.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  // Guest users can not get workspace members\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let member_row =\n    workspace::ops::get_workspace_member_by_uuid(member_uuid, &state.pg_pool, workspace_id)\n      .await\n      .map_err(|_| {\n        AppResponseError::new(\n          ErrorCode::MemberNotFound,\n          format!(\n            \"requested member uid {} is not present in workspace {}\",\n            member_uuid, workspace_id\n          ),\n        )\n      })?;\n  let member = AFWorkspaceMember {\n    name: member_row.name,\n    email: member_row.email,\n    role: member_row.role,\n    avatar_url: member_row.avatar_url,\n    joined_at: member_row.created_at,\n  };\n\n  Ok(AppResponse::Ok().with_data(member).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn open_workspace_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<AFWorkspace>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let workspace =\n    workspace::ops::open_workspace(&state.pg_pool, &user_uuid, uid, &workspace_id).await?;\n  Ok(AppResponse::Ok().with_data(workspace).into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn leave_workspace_handler(\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<()>> {\n  let workspace_id = workspace_id.into_inner();\n  workspace::ops::leave_workspace(\n    &state.pg_pool,\n    &workspace_id,\n    &user_uuid,\n    state.workspace_access_control.clone(),\n  )\n  .await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn update_workspace_member_handler(\n  user_uuid: UserUuid,\n  payload: Json<WorkspaceMemberChangeset>,\n  state: Data<AppState>,\n  workspace_id: web::Path<Uuid>,\n) -> Result<JsonAppResponse<()>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n\n  let changeset = payload.into_inner();\n\n  if changeset.role.is_some() {\n    let changeset_uid = select_uid_from_email(&state.pg_pool, &changeset.email)\n      .await\n      .map_err(AppResponseError::from)?;\n    workspace::ops::update_workspace_member(\n      &changeset_uid,\n      &state.pg_pool,\n      &workspace_id,\n      &changeset,\n      state.workspace_access_control.clone(),\n    )\n    .await?;\n  }\n\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(skip(state, payload))]\nasync fn create_collab_handler(\n  user_uuid: UserUuid,\n  payload: Bytes,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let params = match req.headers().get(X_COMPRESSION_TYPE) {\n    None => serde_json::from_slice::<CreateCollabParams>(&payload).map_err(|err| {\n      AppError::InvalidRequest(format!(\n        \"Failed to parse CreateCollabParams from JSON: {}\",\n        err\n      ))\n    })?,\n    Some(_) => match compress_type_from_header_value(req.headers())? {\n      CompressionType::Brotli { buffer_size } => {\n        let decompress_data = blocking_decompress(payload.to_vec(), buffer_size).await?;\n        CreateCollabParams::from_bytes(&decompress_data).map_err(|err| {\n          AppError::InvalidRequest(format!(\n            \"Failed to parse CreateCollabParams with brotli decompression data: {}\",\n            err\n          ))\n        })?\n      },\n    },\n  };\n\n  let (params, workspace_id) = params.split();\n\n  if params.object_id == workspace_id {\n    // Only the object with [CollabType::Folder] can have the same object_id as workspace_id. But\n    // it should use create workspace API\n    return Err(\n      AppError::InvalidRequest(\"object_id cannot be the same as workspace_id\".to_string()).into(),\n    );\n  }\n\n  let collab = collab_from_encode_collab(&params.object_id, &params.encoded_collab_v1)\n    .await\n    .map_err(|err| {\n      AppError::NoRequiredData(format!(\n        \"Failed to create collab from encoded collab: {}\",\n        err\n      ))\n    })?;\n\n  if let Err(err) = params.collab_type.validate_require_data(&collab) {\n    return Err(\n      AppError::NoRequiredData(format!(\n        \"collab doc state is not correct:{},{}\",\n        params.object_id, err\n      ))\n      .into(),\n    );\n  }\n\n  if state\n    .indexer_scheduler\n    .can_index_workspace(&workspace_id)\n    .await?\n  {\n    if let Ok(paragraphs) = Document::open(collab).map(|doc| doc.paragraphs()) {\n      let pending = UnindexedCollabTask::new(\n        workspace_id,\n        params.object_id,\n        params.collab_type,\n        UnindexedData::Paragraphs(paragraphs),\n      );\n      state\n        .indexer_scheduler\n        .index_pending_collab_one(pending, false)?;\n    }\n  }\n\n  let mut transaction = state\n    .pg_pool\n    .begin()\n    .await\n    .context(\"acquire transaction to upsert collab\")\n    .map_err(AppError::from)?;\n  let start = Instant::now();\n\n  let action = format!(\"Create new collab: {}\", params);\n  state\n    .collab_storage\n    .upsert_new_collab_with_transaction(workspace_id, &uid, params, &mut transaction, &action)\n    .await?;\n\n  transaction\n    .commit()\n    .await\n    .context(\"fail to commit the transaction to upsert collab\")\n    .map_err(AppError::from)?;\n  state.metrics.collab_metrics.observe_pg_tx(start.elapsed());\n\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(skip(state, payload), err)]\nasync fn batch_create_collab_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  mut payload: Payload,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  let compress_type = compress_type_from_header_value(req.headers())?;\n  event!(tracing::Level::DEBUG, \"start decompressing collab list\");\n\n  let mut payload_buffer = Vec::new();\n  let mut offset_len_list = Vec::new();\n  let mut current_offset = 0;\n  let start = Instant::now();\n  while let Some(item) = payload.next().await {\n    if let Ok(bytes) = item {\n      payload_buffer.extend_from_slice(&bytes);\n      while current_offset + 4 <= payload_buffer.len() {\n        // The length of the next frame is determined by the first 4 bytes\n        let size = u32::from_be_bytes([\n          payload_buffer[current_offset],\n          payload_buffer[current_offset + 1],\n          payload_buffer[current_offset + 2],\n          payload_buffer[current_offset + 3],\n        ]) as usize;\n\n        // Ensure there is enough data for the frame (4 bytes for size + `size` bytes for data)\n        if current_offset + 4 + size > payload_buffer.len() {\n          break;\n        }\n\n        // Collect the (offset, len) for the current frame (data starts at current_offset + 4)\n        offset_len_list.push((current_offset + 4, size));\n        current_offset += 4 + size;\n      }\n    }\n  }\n  // Perform decompression and processing in a Rayon thread pool\n  let mut collab_params_list = tokio::task::spawn_blocking(move || match compress_type {\n    CompressionType::Brotli { buffer_size } => offset_len_list\n      .into_par_iter()\n      .filter_map(|(offset, len)| {\n        let compressed_data = &payload_buffer[offset..offset + len];\n        match decompress(compressed_data.to_vec(), buffer_size) {\n          Ok(decompressed_data) => {\n            let params = CreateCollabData::from_bytes(&decompressed_data).ok()?;\n            let params = CollabParams::from(params);\n            if params.validate().is_ok() {\n              let encoded_collab =\n                EncodedCollab::decode_from_bytes(&params.encoded_collab_v1).ok()?;\n              let options = CollabOptions::new(params.object_id.to_string(), default_client_id())\n                .with_data_source(DataSource::DocStateV1(encoded_collab.doc_state.to_vec()));\n              let collab = Collab::new_with_options(CollabOrigin::Empty, options).ok()?;\n\n              match params.collab_type.validate_require_data(&collab) {\n                Ok(_) => {\n                  match params.collab_type {\n                    CollabType::Document => {\n                      let index_text = Document::open(collab).map(|doc| doc.paragraphs());\n                      Some((Some(index_text), params))\n                    },\n                    _ => {\n                      // TODO(nathan): support other types\n                      Some((None, params))\n                    },\n                  }\n                },\n                Err(_) => None,\n              }\n            } else {\n              None\n            }\n          },\n          Err(err) => {\n            error!(\"Failed to decompress data: {:?}\", err);\n            None\n          },\n        }\n      })\n      .collect::<Vec<_>>(),\n  })\n  .await\n  .map_err(|_| AppError::InvalidRequest(\"Failed to decompress data\".to_string()))?;\n\n  if collab_params_list.is_empty() {\n    return Err(AppError::InvalidRequest(\"Empty collab params list\".to_string()).into());\n  }\n\n  let total_size = collab_params_list\n    .iter()\n    .fold(0, |acc, x| acc + x.1.encoded_collab_v1.len());\n  tracing::info!(\n    \"decompressed {} collab objects in {:?}\",\n    collab_params_list.len(),\n    start.elapsed()\n  );\n\n  let mut pending_undexed_collabs = vec![];\n  if state\n    .indexer_scheduler\n    .can_index_workspace(&workspace_id)\n    .await?\n  {\n    pending_undexed_collabs = collab_params_list\n      .iter_mut()\n      .filter(|p| state.indexer_scheduler.is_indexing_enabled(p.1.collab_type))\n      .flat_map(|value| match std::mem::take(&mut value.0) {\n        None => None,\n        Some(text) => text\n          .map(|paragraphs| {\n            UnindexedCollabTask::new(\n              workspace_id,\n              value.1.object_id,\n              value.1.collab_type,\n              UnindexedData::Paragraphs(paragraphs),\n            )\n          })\n          .ok(),\n      })\n      .collect::<Vec<_>>();\n  }\n\n  let collab_params_list = collab_params_list\n    .into_iter()\n    .map(|(_, params)| params)\n    .collect::<Vec<_>>();\n\n  let start = Instant::now();\n  state\n    .collab_storage\n    .batch_insert_new_collab(workspace_id, &uid, collab_params_list)\n    .await?;\n\n  tracing::info!(\n    \"inserted collab objects to disk in {:?}, total size:{}\",\n    start.elapsed(),\n    total_size\n  );\n\n  // Must after batch_insert_new_collab\n  if !pending_undexed_collabs.is_empty() {\n    state\n      .indexer_scheduler\n      .index_pending_collabs(pending_undexed_collabs)?;\n  }\n\n  Ok(Json(AppResponse::Ok()))\n}\n\n// Deprecated\nasync fn get_collab_handler(\n  user_uuid: UserUuid,\n  payload: Json<QueryCollabParams>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<CollabResponse>>> {\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let params = payload.into_inner();\n  params\n    .validate()\n    .map_err(|err| AppError::InvalidRequest(err.to_string()))?;\n\n  let encode_collab = state\n    .collab_storage\n    .get_full_encode_collab(\n      uid.into(),\n      &params.workspace_id,\n      &params.object_id,\n      params.collab_type,\n    )\n    .await\n    .map_err(AppResponseError::from)?\n    .encoded_collab;\n\n  let resp = CollabResponse {\n    encode_collab,\n    object_id: params.object_id,\n  };\n\n  Ok(Json(AppResponse::Ok().with_data(resp)))\n}\n\nasync fn v1_get_collab_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  query: web::Query<CollabTypeParam>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<CollabResponse>>> {\n  let (workspace_id, object_id) = path.into_inner();\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  let encode_collab = state\n    .collab_storage\n    .get_full_encode_collab(uid.into(), &workspace_id, &object_id, query.collab_type)\n    .await\n    .map_err(AppResponseError::from)?\n    .encoded_collab;\n\n  let resp = CollabResponse {\n    encode_collab,\n    object_id,\n  };\n\n  Ok(Json(AppResponse::Ok().with_data(resp)))\n}\n\n#[instrument(level = \"trace\", skip_all)]\nasync fn get_collab_json_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  query: web::Query<CollabTypeParam>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<CollabJsonResponse>>> {\n  let (workspace_id, object_id) = path.into_inner();\n  let collab_type = query.into_inner().collab_type;\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  let doc_state = state\n    .collab_storage\n    .get_full_encode_collab(uid.into(), &workspace_id, &object_id, collab_type)\n    .await\n    .map_err(AppResponseError::from)?\n    .encoded_collab\n    .doc_state;\n  let collab = collab_from_doc_state(doc_state.to_vec(), &object_id, default_client_id())?;\n\n  let resp = CollabJsonResponse {\n    collab: collab.to_json_value(),\n  };\n\n  Ok(Json(AppResponse::Ok().with_data(resp)))\n}\n\n#[instrument(level = \"debug\", skip_all)]\nasync fn post_web_update_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  payload: Json<UpdateCollabWebParams>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let (workspace_id, object_id) = path.into_inner();\n  state\n    .collab_access_control\n    .enforce_action(&workspace_id, &uid, &object_id, Action::Write)\n    .await?;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  trace!(\"create onetime web realtime user: {}\", user);\n\n  let payload = payload.into_inner();\n  let collab_type = payload.collab_type;\n\n  update_page_collab_data(\n    &state,\n    user,\n    workspace_id,\n    object_id,\n    collab_type,\n    payload.doc_state,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(level = \"debug\", skip_all)]\nasync fn get_row_document_collab_exists_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<AFDatabaseRowDocumentCollabExistenceInfo>>> {\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let (workspace_id, object_id) = path.into_inner();\n  state\n    .collab_access_control\n    .enforce_action(&workspace_id, &uid, &object_id, Action::Read)\n    .await?;\n  let exists = check_if_row_document_collab_exists(&state.pg_pool, &object_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(\n    AFDatabaseRowDocumentCollabExistenceInfo { exists },\n  )))\n}\n\nasync fn post_space_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<CreateSpaceParams>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<Space>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let space = create_space(\n    &state,\n    user,\n    workspace_uuid,\n    &payload.space_permission,\n    &payload.name,\n    &payload.space_icon,\n    &payload.space_icon_color,\n    payload.view_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(space)))\n}\n\nasync fn update_space_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<UpdateSpaceParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<Space>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_space(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    &payload.space_permission,\n    &payload.name,\n    &payload.space_icon,\n    &payload.space_icon_color,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn post_folder_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<CreateFolderViewParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<Page>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let page = create_folder_view(\n    &state,\n    user,\n    workspace_uuid,\n    &payload.parent_view_id,\n    payload.layout.clone(),\n    payload.name.as_deref(),\n    payload.view_id,\n    payload.database_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(page)))\n}\n\nasync fn post_page_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<CreatePageParams>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<Page>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let page = create_page(\n    &state,\n    user,\n    workspace_uuid,\n    &payload.parent_view_id,\n    &payload.layout,\n    payload.name.as_deref(),\n    payload.page_data.as_ref(),\n    payload.view_id,\n    payload.collab_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(page)))\n}\n\nasync fn post_orphaned_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<CreateOrphanedViewParams>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  create_orphaned_view(\n    uid,\n    &state.pg_pool,\n    &state.collab_storage,\n    workspace_uuid,\n    payload.document_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn append_block_to_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<AppendBlockToPageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let serde_blocks: Vec<Result<SerdeBlock, AppError>> = payload\n    .blocks\n    .iter()\n    .map(|value| {\n      serde_json::from_value(value.clone()).map_err(|err| AppError::InvalidBlock(err.to_string()))\n    })\n    .collect_vec();\n  let serde_blocks = serde_blocks\n    .into_iter()\n    .collect::<Result<Vec<SerdeBlock>, AppError>>()?;\n  append_block_at_the_end_of_page(&state, user, workspace_uuid, &view_id, &serde_blocks).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn move_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<MovePageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  move_page(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    &payload.new_parent_view_id,\n    payload.prev_view_id.clone(),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn reorder_favorite_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<ReorderFavoritePageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  reorder_favorite_page(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    payload.prev_view_id.as_deref(),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn duplicate_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  payload: Json<DuplicatePageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let suffix = payload.suffix.as_deref().unwrap_or(\" (Copy)\").to_string();\n  duplicate_view_tree_and_collab(&state, user, workspace_uuid, view_id, &suffix).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn move_page_to_trash_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  move_page_to_trash(&state, user, workspace_uuid, &view_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn restore_page_from_trash_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  restore_page_from_trash(&state, user, workspace_uuid, &view_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn add_recent_pages_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  payload: Json<AddRecentPagesParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let AddRecentPagesParams { recent_view_ids } = payload.into_inner();\n  add_recent_pages(&state, user, workspace_uuid, recent_view_ids).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn restore_all_pages_from_trash_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_uuid = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  restore_all_pages_from_trash(&state, user, workspace_uuid).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_page_from_trash_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let (workspace_id, view_id) = path.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  delete_trash(&state, user, workspace_id, &view_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_all_pages_from_trash_handler(\n  user_uuid: UserUuid,\n  path: web::Path<Uuid>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let workspace_id = path.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  delete_all_pages_from_trash(&state, user, workspace_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(level = \"trace\", skip_all)]\nasync fn publish_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  payload: Json<PublishPageParams>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let (workspace_id, view_id) = path.into_inner();\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let PublishPageParams {\n    publish_name,\n    visible_database_view_ids,\n    comments_enabled,\n    duplicate_enabled,\n  } = payload.into_inner();\n  publish_page(\n    &state,\n    uid,\n    *user_uuid,\n    workspace_id,\n    view_id,\n    visible_database_view_ids,\n    publish_name,\n    comments_enabled.unwrap_or(true),\n    duplicate_enabled.unwrap_or(true),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn unpublish_page_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let (workspace_uuid, view_uuid) = path.into_inner();\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_uuid, AFRole::Member)\n    .await?;\n  unpublish_page(\n    state.published_collab_store.as_ref(),\n    workspace_uuid,\n    *user_uuid,\n    view_uuid,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn post_page_database_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  payload: Json<CreatePageDatabaseViewParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  create_database_view(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    &payload.layout,\n    payload.name.as_deref(),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn update_page_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<UpdatePageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let icon = payload.icon.as_ref();\n  let is_locked = payload.is_locked;\n  let extra = payload\n    .extra\n    .as_ref()\n    .map(|json_value| json_value.to_string());\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_page(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    &payload.name,\n    icon,\n    is_locked,\n    extra.as_ref(),\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn update_page_name_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<UpdatePageNameParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_page_name(&state, user, workspace_uuid, &view_id, &payload.name).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn update_page_icon_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<UpdatePageIconParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let icon = &payload.icon;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_page_icon(&state, user, workspace_uuid, &view_id, Some(icon)).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn update_page_extra_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<UpdatePageExtraParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_page_extra(&state, user, workspace_uuid, &view_id, &payload.extra).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn remove_page_icon_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  update_page_icon(&state, user, workspace_uuid, &view_id, None).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_page_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PageCollab>>> {\n  let (workspace_uuid, view_id) = path.into_inner();\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  let page_collab = get_page_view_collab(\n    &state.pg_pool,\n    &state.collab_storage,\n    &state.ws_server,\n    uid,\n    workspace_uuid,\n    view_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(page_collab)))\n}\n\nasync fn favorite_page_view_handler(\n  user_uuid: UserUuid,\n  path: web::Path<(Uuid, String)>,\n  payload: Json<FavoritePageParams>,\n  state: Data<AppState>,\n\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let (workspace_uuid, view_id) = path.into_inner();\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  favorite_page(\n    &state,\n    user,\n    workspace_uuid,\n    &view_id,\n    payload.is_favorite,\n    payload.is_pinned,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(level = \"debug\", skip(payload, state), err)]\nasync fn batch_get_collab_handler(\n  user_uuid: UserUuid,\n  path: Path<Uuid>,\n  state: Data<AppState>,\n  payload: Json<BatchQueryCollabParams>,\n) -> Result<Json<AppResponse<BatchQueryCollabResult>>> {\n  let workspace_id = path.into_inner();\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n  let result = BatchQueryCollabResult(\n    state\n      .collab_storage\n      .batch_get_collab(&uid, workspace_id, payload.into_inner().0)\n      .await,\n  );\n  Ok(Json(AppResponse::Ok().with_data(result)))\n}\n\n#[instrument(skip(state, payload), err)]\nasync fn update_collab_handler(\n  user_uuid: UserUuid,\n  payload: Json<CreateCollabParams>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let (params, workspace_id) = payload.into_inner().split();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n\n  let create_params = CreateCollabParams::from((workspace_id, params));\n  let (params, workspace_id) = create_params.split();\n  if state\n    .indexer_scheduler\n    .can_index_workspace(&workspace_id)\n    .await?\n  {\n    match params.collab_type {\n      CollabType::Document => {\n        let collab = collab_from_encode_collab(&params.object_id, &params.encoded_collab_v1)\n          .await\n          .map_err(|err| {\n            AppError::InvalidRequest(format!(\n              \"Failed to create collab from encoded collab: {}\",\n              err\n            ))\n          })?;\n        params\n          .collab_type\n          .validate_require_data(&collab)\n          .map_err(|err| {\n            AppError::NoRequiredData(format!(\n              \"collab doc state is not correct:{},{}\",\n              params.object_id, err\n            ))\n          })?;\n\n        if let Ok(paragraphs) = Document::open(collab).map(|doc| doc.paragraphs()) {\n          if !paragraphs.is_empty() {\n            let pending = UnindexedCollabTask::new(\n              workspace_id,\n              params.object_id,\n              params.collab_type,\n              UnindexedData::Paragraphs(paragraphs),\n            );\n            state\n              .indexer_scheduler\n              .index_pending_collab_one(pending, true)?;\n          }\n        }\n      },\n      _ => {\n        // TODO(nathan): support other collab type\n      },\n    }\n  }\n\n  state\n    .collab_storage\n    .upsert_collab_background(workspace_id, &uid, params)\n    .await?;\n  Ok(AppResponse::Ok().into())\n}\n\n#[instrument(level = \"info\", skip(state, payload), err)]\nasync fn delete_collab_handler(\n  user_uuid: UserUuid,\n  payload: Json<DeleteCollabParams>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let payload = payload.into_inner();\n  payload.validate().map_err(AppError::from)?;\n\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  state\n    .collab_storage\n    .delete_collab(&payload.workspace_id, &uid, &payload.object_id)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  Ok(AppResponse::Ok().into())\n}\n\nasync fn put_workspace_default_published_view_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  payload: Json<UpdateDefaultPublishView>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  let new_default_pub_view_id = payload.into_inner().view_id;\n  biz::workspace::publish::set_workspace_default_publish_view(\n    &state.pg_pool,\n    &workspace_id,\n    &new_default_pub_view_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_workspace_default_published_view_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  biz::workspace::publish::unset_workspace_default_publish_view(&state.pg_pool, &workspace_id)\n    .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_workspace_published_default_info_handler(\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PublishInfo>>> {\n  let workspace_id = workspace_id.into_inner();\n  let info =\n    biz::workspace::publish::get_workspace_default_publish_view_info(&state.pg_pool, &workspace_id)\n      .await?;\n  Ok(Json(AppResponse::Ok().with_data(info)))\n}\n\nasync fn put_publish_namespace_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  payload: Json<UpdatePublishNamespace>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  let UpdatePublishNamespace {\n    old_namespace,\n    new_namespace,\n  } = payload.into_inner();\n  biz::workspace::publish::set_workspace_namespace(\n    &state.pg_pool,\n    &workspace_id,\n    &old_namespace,\n    &new_namespace,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_publish_namespace_handler(\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<String>>> {\n  let workspace_id = workspace_id.into_inner();\n  let namespace =\n    biz::workspace::publish::get_workspace_publish_namespace(&state.pg_pool, &workspace_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(namespace)))\n}\n\nasync fn get_default_published_collab_info_meta_handler(\n  publish_namespace: web::Path<String>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PublishInfoMeta<serde_json::Value>>>> {\n  let publish_namespace = publish_namespace.into_inner();\n  let (info, meta) =\n    get_workspace_default_publish_view_info_meta(&state.pg_pool, &publish_namespace).await?;\n  Ok(Json(\n    AppResponse::Ok().with_data(PublishInfoMeta { info, meta }),\n  ))\n}\n\nasync fn get_v1_published_collab_handler(\n  path_param: web::Path<(String, String)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<serde_json::Value>>> {\n  let (workspace_namespace, publish_name) = path_param.into_inner();\n  let metadata = state\n    .published_collab_store\n    .get_collab_metadata(&workspace_namespace, &publish_name)\n    .await?;\n  Ok(Json(AppResponse::Ok().with_data(metadata)))\n}\n\nasync fn get_published_collab_blob_handler(\n  path_param: web::Path<(String, String)>,\n  state: Data<AppState>,\n) -> Result<Vec<u8>> {\n  let (publish_namespace, publish_name) = path_param.into_inner();\n  let collab_data = state\n    .published_collab_store\n    .get_collab_blob_by_publish_namespace(&publish_namespace, &publish_name)\n    .await?;\n  Ok(collab_data)\n}\n\nasync fn post_published_duplicate_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n  params: Json<PublishedDuplicate>,\n) -> Result<Json<AppResponse<DuplicatePublishedPageResponse>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n  let params = params.into_inner();\n  let root_view_id_for_duplicate =\n    biz::workspace::publish_dup::duplicate_published_collab_to_workspace(\n      &state,\n      uid,\n      params.published_view_id,\n      workspace_id,\n      params.dest_view_id,\n    )\n    .await?;\n\n  Ok(Json(AppResponse::Ok().with_data(\n    DuplicatePublishedPageResponse {\n      view_id: root_view_id_for_duplicate,\n    },\n  )))\n}\n\nasync fn list_published_collab_info_handler(\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<Vec<PublishInfoView>>>> {\n  let uid = DUMMY_UID;\n  let publish_infos = biz::workspace::publish::list_collab_publish_info(\n    state.published_collab_store.as_ref(),\n    &state.ws_server,\n    workspace_id.into_inner(),\n    uid,\n  )\n  .await?;\n\n  Ok(Json(AppResponse::Ok().with_data(publish_infos)))\n}\n\n// Deprecated since 0.7.4\nasync fn get_published_collab_info_handler(\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PublishInfo>>> {\n  let view_id = view_id.into_inner();\n  let collab_data = state\n    .published_collab_store\n    .get_collab_publish_info(&view_id)\n    .await?;\n  if collab_data.unpublished_timestamp.is_some() {\n    return Err(AppError::RecordNotFound(\"Collab is unpublished\".to_string()).into());\n  }\n  Ok(Json(AppResponse::Ok().with_data(collab_data)))\n}\n\nasync fn get_v1_published_collab_info_handler(\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PublishInfo>>> {\n  let view_id = view_id.into_inner();\n  let collab_data = state\n    .published_collab_store\n    .get_collab_publish_info(&view_id)\n    .await?;\n  Ok(Json(AppResponse::Ok().with_data(collab_data)))\n}\n\nasync fn get_published_collab_comment_handler(\n  view_id: web::Path<Uuid>,\n  optional_user_uuid: OptionalUserUuid,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<GlobalComments>> {\n  let view_id = view_id.into_inner();\n  let comments =\n    get_comments_on_published_view(&state.pg_pool, &view_id, &optional_user_uuid).await?;\n  let resp = GlobalComments { comments };\n  Ok(Json(AppResponse::Ok().with_data(resp)))\n}\n\nasync fn post_published_collab_comment_handler(\n  user_uuid: UserUuid,\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n  data: Json<CreateGlobalCommentParams>,\n) -> Result<JsonAppResponse<()>> {\n  let view_id = view_id.into_inner();\n  create_comment_on_published_view(\n    &state.pg_pool,\n    &view_id,\n    &data.reply_comment_id,\n    &data.content,\n    &user_uuid,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_published_collab_comment_handler(\n  user_uuid: UserUuid,\n  view_id: web::Path<Uuid>,\n  state: Data<AppState>,\n  data: Json<DeleteGlobalCommentParams>,\n) -> Result<JsonAppResponse<()>> {\n  let view_id = view_id.into_inner();\n  remove_comment_on_published_view(&state.pg_pool, &view_id, &data.comment_id, &user_uuid).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_published_collab_reaction_handler(\n  view_id: web::Path<Uuid>,\n  query: web::Query<GetReactionQueryParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<Reactions>> {\n  let view_id = view_id.into_inner();\n  let reactions =\n    get_reactions_on_published_view(&state.pg_pool, &view_id, &query.comment_id).await?;\n  let resp = Reactions { reactions };\n  Ok(Json(AppResponse::Ok().with_data(resp)))\n}\n\nasync fn post_published_collab_reaction_handler(\n  user_uuid: UserUuid,\n  view_id: web::Path<Uuid>,\n  data: Json<CreateReactionParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let view_id = view_id.into_inner();\n  create_reaction_on_comment(\n    &state.pg_pool,\n    &data.comment_id,\n    &view_id,\n    &data.reaction_type,\n    &user_uuid,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_published_collab_reaction_handler(\n  user_uuid: UserUuid,\n  data: Json<DeleteReactionParams>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  remove_reaction_on_comment(\n    &state.pg_pool,\n    &data.comment_id,\n    &data.reaction_type,\n    &user_uuid,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\n// FIXME: This endpoint currently has a different behaviour from the publish page endpoint,\n// as it doesn't accept parameters. We will need to deprecate this endpoint and use a new\n// one that accepts parameters.\n#[instrument(level = \"trace\", skip_all)]\nasync fn post_publish_collabs_handler(\n  workspace_id: web::Path<Uuid>,\n  user_uuid: UserUuid,\n  payload: Payload,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<()>>> {\n  let workspace_id = workspace_id.into_inner();\n\n  let mut accumulator = Vec::<PublishCollabItem<serde_json::Value, Vec<u8>>>::new();\n  let mut payload_reader: PayloadReader = PayloadReader::new(payload);\n\n  loop {\n    let meta: PublishCollabMetadata<serde_json::Value> = {\n      let meta_len = payload_reader.read_u32_little_endian().await?;\n      if meta_len > 4 * 1024 * 1024 {\n        // 4MiB Limit for metadata\n        return Err(AppError::InvalidRequest(String::from(\"metadata too large\")).into());\n      }\n      if meta_len == 0 {\n        break;\n      }\n\n      let mut meta_buffer = vec![0; meta_len as usize];\n      payload_reader.read_exact(&mut meta_buffer).await?;\n      serde_json::from_slice(&meta_buffer)?\n    };\n\n    let data = {\n      let data_len = payload_reader.read_u32_little_endian().await?;\n      if data_len > 32 * 1024 * 1024 {\n        // 32MiB Limit for data\n        return Err(AppError::InvalidRequest(String::from(\"data too large\")).into());\n      }\n      let mut data_buffer = vec![0; data_len as usize];\n      payload_reader.read_exact(&mut data_buffer).await?;\n      data_buffer\n    };\n\n    // Set comments_enabled and duplicate_enabled to true by default, as this is the default\n    // behaviour for the older web version.\n    accumulator.push(PublishCollabItem {\n      meta,\n      data,\n      comments_enabled: true,\n      duplicate_enabled: true,\n    });\n  }\n\n  if accumulator.is_empty() {\n    return Err(\n      AppError::InvalidRequest(String::from(\"did not receive any data to publish\")).into(),\n    );\n  }\n  state\n    .published_collab_store\n    .publish_collabs(accumulator, &workspace_id, &user_uuid)\n    .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn patch_published_collabs_handler(\n  workspace_id: web::Path<Uuid>,\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  patches: Json<Vec<PatchPublishedCollab>>,\n) -> Result<Json<AppResponse<()>>> {\n  let workspace_id = workspace_id.into_inner();\n  if patches.is_empty() {\n    return Err(AppError::InvalidRequest(\"No patches provided\".to_string()).into());\n  }\n  state\n    .published_collab_store\n    .patch_collabs(&workspace_id, &user_uuid, &patches)\n    .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_published_collabs_handler(\n  workspace_id: web::Path<Uuid>,\n  user_uuid: UserUuid,\n  state: Data<AppState>,\n  view_ids: Json<Vec<Uuid>>,\n) -> Result<Json<AppResponse<()>>> {\n  let workspace_id = workspace_id.into_inner();\n  let view_ids = view_ids.into_inner();\n  if view_ids.is_empty() {\n    return Err(AppError::InvalidRequest(\"No view_ids provided\".to_string()).into());\n  }\n  state\n    .published_collab_store\n    .unpublish_collabs(&workspace_id, &view_ids, &user_uuid)\n    .await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(level = \"info\", skip_all, err)]\nasync fn post_realtime_message_stream_handler(\n  user_uuid: UserUuid,\n  mut payload: Payload,\n  server: Data<RealtimeServerAddr>,\n  state: Data<AppState>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<()>>> {\n  let device_id = device_id_from_headers(req.headers())\n    .map(|s| s.to_string())\n    .unwrap_or_else(|_| \"\".to_string());\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  let mut bytes = BytesMut::new();\n  while let Some(item) = payload.next().await {\n    bytes.extend_from_slice(&item?);\n  }\n\n  let device_id = device_id.to_string();\n\n  let message = parser_realtime_msg(bytes.freeze(), req.clone()).await?;\n  let stream_message = ClientHttpStreamMessage {\n    uid,\n    device_id,\n    message,\n  };\n\n  // When the server is under heavy load, try_send may fail. In client side, it will retry to send\n  // the message later.\n  match server.try_send(stream_message) {\n    Ok(_) => return Ok(Json(AppResponse::Ok())),\n    Err(err) => Err(\n      AppError::Internal(anyhow!(\n        \"Failed to send message to websocket server, error:{}\",\n        err\n      ))\n      .into(),\n    ),\n  }\n}\n\nasync fn get_workspace_usage_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<WorkspaceUsage>>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  let res =\n    biz::workspace::ops::get_workspace_document_total_bytes(&state.pg_pool, &workspace_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(res)))\n}\n\nasync fn get_workspace_folder_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n\n  query: web::Query<QueryWorkspaceFolder>,\n  req: HttpRequest,\n) -> Result<Json<AppResponse<FolderView>>> {\n  let depth = query.depth.unwrap_or(1);\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let user = realtime_user_for_web_request(req.headers(), uid)?;\n  let workspace_id = workspace_id.into_inner();\n  // shuheng: AppFlowy Web does not support guest editor yet, so we need to make sure\n  // that the user is at least a member of the workspace, not just a guest.\n  state\n    .workspace_access_control\n    .enforce_role_weak(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let root_view_id = query.root_view_id.unwrap_or(workspace_id);\n  let folder_view = biz::collab::ops::get_user_workspace_structure(\n    &state,\n    user,\n    workspace_id,\n    depth,\n    &root_view_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(folder_view)))\n}\n\nasync fn get_recent_views_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<RecentSectionItems>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let folder_views =\n    get_user_recent_folder_views(&state.ws_server, &state.pg_pool, uid, workspace_id).await?;\n  let section_items = RecentSectionItems {\n    views: folder_views,\n  };\n  Ok(Json(AppResponse::Ok().with_data(section_items)))\n}\n\nasync fn get_favorite_views_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<FavoriteSectionItems>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let folder_views =\n    get_user_favorite_folder_views(&state.ws_server, &state.pg_pool, uid, workspace_id).await?;\n  let section_items = FavoriteSectionItems {\n    views: folder_views,\n  };\n  Ok(Json(AppResponse::Ok().with_data(section_items)))\n}\n\nasync fn get_trash_views_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<TrashSectionItems>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n  let folder_views = get_user_trash_folder_views(&state.ws_server, uid, workspace_id).await?;\n  let section_items = TrashSectionItems {\n    views: folder_views,\n  };\n  Ok(Json(AppResponse::Ok().with_data(section_items)))\n}\n\nasync fn get_workspace_publish_outline_handler(\n  publish_namespace: web::Path<String>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<PublishedView>>> {\n  let uid = DUMMY_UID;\n  let published_view = biz::collab::ops::get_published_view(\n    &state.ws_server,\n    publish_namespace.into_inner(),\n    &state.pg_pool,\n    uid,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(published_view)))\n}\n\nasync fn list_database_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<Vec<AFDatabase>>>> {\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let workspace_id = workspace_id.into_inner();\n  let dbs = biz::collab::ops::list_database(\n    &state.pg_pool,\n    &state.ws_server,\n    &state.collab_storage,\n    uid,\n    workspace_id,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(dbs)))\n}\n\nasync fn list_database_row_id_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<Vec<AFDatabaseRow>>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n\n  let db_rows =\n    biz::collab::ops::list_database_row_ids(&state.collab_storage, workspace_id, db_id).await?;\n  Ok(Json(AppResponse::Ok().with_data(db_rows)))\n}\n\nasync fn post_database_row_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  add_database_row: Json<AddDatatabaseRow>,\n) -> Result<Json<AppResponse<String>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let AddDatatabaseRow { cells, document } = add_database_row.into_inner();\n\n  let new_db_row_id =\n    biz::collab::ops::insert_database_row(&state, workspace_id, db_id, uid, None, cells, document)\n      .await?;\n  Ok(Json(AppResponse::Ok().with_data(new_db_row_id)))\n}\n\nasync fn put_database_row_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  upsert_db_row: Json<UpsertDatatabaseRow>,\n) -> Result<Json<AppResponse<String>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let UpsertDatatabaseRow {\n    pre_hash,\n    cells,\n    document,\n  } = upsert_db_row.into_inner();\n\n  let row_id = {\n    let mut hasher = Sha256::new();\n    hasher.update(workspace_id);\n    hasher.update(db_id);\n    hasher.update(pre_hash);\n    let hash = hasher.finalize();\n    Uuid::from_bytes([\n      // take 16 out of 32 bytes\n      hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9],\n      hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],\n    ])\n  };\n\n  biz::collab::ops::upsert_database_row(&state, workspace_id, db_id, uid, row_id, cells, document)\n    .await?;\n  Ok(Json(AppResponse::Ok().with_data(row_id.to_string())))\n}\n\nasync fn get_database_fields_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<Vec<AFDatabaseField>>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n\n  let db_fields =\n    biz::collab::ops::get_database_fields(&state.collab_storage, workspace_id, db_id).await?;\n\n  Ok(Json(AppResponse::Ok().with_data(db_fields)))\n}\n\nasync fn post_database_fields_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  field: Json<AFInsertDatabaseField>,\n) -> Result<Json<AppResponse<String>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Write)\n    .await?;\n\n  let field_id =\n    biz::collab::ops::add_database_field(&state, workspace_id, db_id, field.into_inner()).await?;\n\n  Ok(Json(AppResponse::Ok().with_data(field_id)))\n}\n\nasync fn list_database_row_id_updated_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  param: web::Query<ListDatabaseRowUpdatedParam>,\n) -> Result<Json<AppResponse<Vec<DatabaseRowUpdatedItem>>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n\n  // Default to 1 hour ago\n  let after: DateTime<Utc> = param\n    .after\n    .unwrap_or_else(|| Utc::now() - Duration::hours(1));\n\n  let db_rows = biz::collab::ops::list_database_row_ids_updated(\n    &state.collab_storage,\n    &state.pg_pool,\n    workspace_id,\n    db_id,\n    &after,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(db_rows)))\n}\n\nasync fn list_database_row_details_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  param: web::Query<ListDatabaseRowDetailParam>,\n) -> Result<Json<AppResponse<Vec<AFDatabaseRowDetail>>>> {\n  let (workspace_id, db_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let list_db_row_query = param.into_inner();\n  let with_doc = list_db_row_query.with_doc.unwrap_or_default();\n  let row_ids = list_db_row_query.into_ids()?;\n\n  state\n    .workspace_access_control\n    .enforce_action(&uid, &workspace_id, Action::Read)\n    .await?;\n\n  static UNSUPPORTED_FIELD_TYPES: &[FieldType] = &[FieldType::Relation];\n\n  let db_rows = biz::collab::ops::list_database_row_details(\n    &state.collab_storage,\n    uid,\n    workspace_id,\n    db_id,\n    &row_ids,\n    UNSUPPORTED_FIELD_TYPES,\n    with_doc,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(db_rows)))\n}\n\n#[inline]\nasync fn parser_realtime_msg(\n  payload: Bytes,\n  req: HttpRequest,\n) -> Result<RealtimeMessage, AppError> {\n  let HttpRealtimeMessage {\n    device_id: _,\n    payload,\n  } =\n    HttpRealtimeMessage::decode(payload.as_ref()).map_err(|err| AppError::Internal(err.into()))?;\n  let payload = match req.headers().get(X_COMPRESSION_TYPE) {\n    None => payload,\n    Some(_) => match compress_type_from_header_value(req.headers())? {\n      CompressionType::Brotli { buffer_size } => {\n        let decompressed_data = blocking_decompress(payload, buffer_size).await?;\n        event!(\n          tracing::Level::TRACE,\n          \"Decompress realtime http message with len: {}\",\n          decompressed_data.len()\n        );\n        decompressed_data\n      },\n    },\n  };\n  let message = Message::from(payload);\n  match message {\n    Message::Binary(bytes) => {\n      let realtime_msg = tokio::task::spawn_blocking(move || {\n        RealtimeMessage::decode(&bytes).map_err(|err| {\n          AppError::InvalidRequest(format!(\"Failed to parse RealtimeMessage: {}\", err))\n        })\n      })\n      .await\n      .map_err(AppError::from)??;\n      Ok(realtime_msg)\n    },\n    _ => Err(AppError::InvalidRequest(format!(\n      \"Unsupported message type: {:?}\",\n      message\n    ))),\n  }\n}\n\n#[instrument(level = \"debug\", skip_all)]\nasync fn get_collab_embed_info_handler(\n  path: web::Path<(String, Uuid)>,\n  state: Data<AppState>,\n) -> Result<Json<AppResponse<AFCollabEmbedInfo>>> {\n  let (_, object_id) = path.into_inner();\n  let info = database::collab::select_collab_embed_info(&state.pg_pool, &object_id)\n    .await\n    .map_err(AppResponseError::from)?\n    .ok_or_else(|| {\n      AppError::RecordNotFound(format!(\n        \"Embedding for given object:{} not found\",\n        object_id\n      ))\n    })?;\n  Ok(Json(AppResponse::Ok().with_data(info)))\n}\n\nasync fn force_generate_collab_embedding_handler(\n  path: web::Path<(Uuid, Uuid)>,\n  server: Data<RealtimeServerAddr>,\n) -> Result<Json<AppResponse<()>>> {\n  let (workspace_id, object_id) = path.into_inner();\n  let request = ClientGenerateEmbeddingMessage {\n    workspace_id,\n    object_id,\n    return_tx: None,\n  };\n  let _ = server.try_send(request);\n  Ok(Json(AppResponse::Ok()))\n}\n\n#[instrument(level = \"debug\", skip_all)]\nasync fn batch_get_collab_embed_info_handler(\n  state: Data<AppState>,\n  payload: Json<RepeatedEmbeddedCollabQuery>,\n) -> Result<Json<AppResponse<RepeatedAFCollabEmbedInfo>>> {\n  let payload = payload.into_inner();\n  let info = database::collab::batch_select_collab_embed(&state.pg_pool, payload.0)\n    .await\n    .map_err(AppResponseError::from)?;\n  Ok(Json(AppResponse::Ok().with_data(info)))\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\nasync fn collab_full_sync_handler(\n  user_uuid: UserUuid,\n  body: Bytes,\n  path: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  server: Data<RealtimeServerAddr>,\n  req: HttpRequest,\n) -> Result<HttpResponse> {\n  if body.is_empty() {\n    return Err(AppError::InvalidRequest(\"body is empty\".to_string()).into());\n  }\n\n  // when the payload size exceeds the limit, we consider it as an invalid payload.\n  const MAX_BODY_SIZE: usize = 1024 * 1024 * 50; // 50MB\n  if body.len() > MAX_BODY_SIZE {\n    error!(\"Unexpected large body size: {}\", body.len());\n    return Err(\n      AppError::InvalidRequest(format!(\"body size exceeds limit: {}\", MAX_BODY_SIZE)).into(),\n    );\n  }\n\n  let (workspace_id, object_id) = path.into_inner();\n  let params = CollabDocStateParams::decode(&mut Cursor::new(body)).map_err(|err| {\n    AppError::InvalidRequest(format!(\"Failed to parse CollabDocStateParams: {}\", err))\n  })?;\n\n  if params.doc_state.is_empty() {\n    return Err(AppError::InvalidRequest(\"doc state is empty\".to_string()).into());\n  }\n\n  let collab_type = CollabType::from(params.collab_type);\n  let compression_type = PayloadCompressionType::try_from(params.compression).map_err(|err| {\n    AppError::InvalidRequest(format!(\"Failed to parse PayloadCompressionType: {}\", err))\n  })?;\n\n  let doc_state = match compression_type {\n    PayloadCompressionType::None => params.doc_state,\n    PayloadCompressionType::Zstd => tokio::task::spawn_blocking(move || {\n      zstd::decode_all(&*params.doc_state)\n        .map_err(|err| AppError::InvalidRequest(format!(\"Failed to decompress doc_state: {}\", err)))\n    })\n    .await\n    .map_err(AppError::from)??,\n  };\n\n  let sv = match compression_type {\n    PayloadCompressionType::None => params.sv,\n    PayloadCompressionType::Zstd => tokio::task::spawn_blocking(move || {\n      zstd::decode_all(&*params.sv)\n        .map_err(|err| AppError::InvalidRequest(format!(\"Failed to decompress sv: {}\", err)))\n    })\n    .await\n    .map_err(AppError::from)??,\n  };\n\n  let app_version = client_version_from_headers(req.headers())\n    .map(|s| s.to_string())\n    .unwrap_or_else(|_| \"\".to_string());\n  let device_id = device_id_from_headers(req.headers())\n    .map(|s| s.to_string())\n    .unwrap_or_else(|_| \"\".to_string());\n\n  let uid = state\n    .user_cache\n    .get_user_uid(&user_uuid)\n    .await\n    .map_err(AppResponseError::from)?;\n\n  let user = RealtimeUser {\n    uid,\n    device_id,\n    connect_at: timestamp(),\n    session_id: Uuid::new_v4().to_string(),\n    app_version,\n  };\n\n  let (tx, rx) = tokio::sync::oneshot::channel();\n  let message = ClientHttpUpdateMessage {\n    user,\n    workspace_id,\n    object_id,\n    collab_type,\n    update: Bytes::from(doc_state),\n    state_vector: Some(Bytes::from(sv)),\n    return_tx: Some(tx),\n  };\n\n  server\n    .try_send(message)\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to send message to server: {}\", err)))?;\n\n  match rx\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to receive message from server: {}\", err)))?\n  {\n    Ok(Some(data)) => {\n      let encoded = tokio::task::spawn_blocking(move || zstd::encode_all(Cursor::new(data), 3))\n        .await\n        .map_err(|err| AppError::Internal(anyhow!(\"Failed to compress data: {}\", err)))??;\n\n      Ok(HttpResponse::Ok().body(encoded))\n    },\n    Ok(None) => Ok(HttpResponse::InternalServerError().finish()),\n    Err(err) => Ok(err.error_response()),\n  }\n}\n\nasync fn post_quick_note_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n  data: Json<CreateQuickNoteParams>,\n) -> Result<JsonAppResponse<QuickNote>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let data = data.into_inner();\n  let quick_note = create_quick_note(&state.pg_pool, uid, workspace_id, data.data.as_ref()).await?;\n  Ok(Json(AppResponse::Ok().with_data(quick_note)))\n}\n\nasync fn list_quick_notes_handler(\n  user_uuid: UserUuid,\n  workspace_id: web::Path<Uuid>,\n  state: Data<AppState>,\n  query: web::Query<ListQuickNotesQueryParams>,\n) -> Result<JsonAppResponse<QuickNotes>> {\n  let workspace_id = workspace_id.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let ListQuickNotesQueryParams {\n    search_term,\n    offset,\n    limit,\n  } = query.into_inner();\n  let quick_notes = list_quick_notes(\n    &state.pg_pool,\n    uid,\n    workspace_id,\n    search_term,\n    offset,\n    limit,\n  )\n  .await?;\n  Ok(Json(AppResponse::Ok().with_data(quick_notes)))\n}\n\nasync fn update_quick_note_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n  data: Json<UpdateQuickNoteParams>,\n) -> Result<JsonAppResponse<()>> {\n  let (workspace_id, quick_note_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  update_quick_note(&state.pg_pool, quick_note_id, &data.data).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_quick_note_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<(Uuid, Uuid)>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let (workspace_id, quick_note_id) = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  delete_quick_note(&state.pg_pool, quick_note_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn delete_workspace_invite_code_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<()>> {\n  let workspace_id = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  delete_workspace_invite_code(&state.pg_pool, &workspace_id).await?;\n  Ok(Json(AppResponse::Ok()))\n}\n\nasync fn get_workspace_invite_code_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<Uuid>,\n  state: Data<AppState>,\n) -> Result<JsonAppResponse<WorkspaceInviteToken>> {\n  let workspace_id = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Member)\n    .await?;\n  let code = get_invite_code_for_workspace(&state.pg_pool, &workspace_id).await?;\n  Ok(Json(\n    AppResponse::Ok().with_data(WorkspaceInviteToken { code }),\n  ))\n}\n\nasync fn post_workspace_invite_code_handler(\n  user_uuid: UserUuid,\n  path_param: web::Path<Uuid>,\n  state: Data<AppState>,\n  data: Json<WorkspaceInviteCodeParams>,\n) -> Result<JsonAppResponse<WorkspaceInviteToken>> {\n  let workspace_id = path_param.into_inner();\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  state\n    .workspace_access_control\n    .enforce_role_strong(&uid, &workspace_id, AFRole::Owner)\n    .await?;\n  let workspace_invite_link =\n    generate_workspace_invite_token(&state.pg_pool, &workspace_id, data.validity_period_hours)\n      .await?;\n  Ok(Json(AppResponse::Ok().with_data(workspace_invite_link)))\n}\n"
  },
  {
    "path": "src/api/ws.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::time::Duration;\n\nuse crate::biz::authentication::jwt::{authorization_from_token, UserUuid};\nuse crate::state::AppState;\nuse actix::Addr;\nuse actix_http::header::AUTHORIZATION;\nuse actix_web::web::{Data, Path, Payload};\nuse actix_web::{get, web, HttpRequest, HttpResponse, Result, Scope};\nuse actix_web_actors::ws;\nuse app_error::AppError;\nuse appflowy_collaborate::actix_ws::client::rt_client::RealtimeClient;\nuse appflowy_collaborate::actix_ws::server::RealtimeServerActor;\nuse appflowy_collaborate::ws2::{SessionInfo, WsSession};\nuse appflowy_proto::{ServerMessage, WorkspaceNotification};\nuse collab_rt_entity::user::{AFUserChange, RealtimeUser, UserMessage};\nuse collab_rt_entity::{max_sync_message_size, RealtimeMessage};\nuse collab_stream::model::MessageId;\nuse secrecy::Secret;\nuse semver::Version;\nuse shared_entity::response::AppResponseError;\nuse tokio::sync::mpsc;\nuse tokio::sync::mpsc::Sender;\nuse tracing::{debug, error, instrument, trace};\nuse uuid::Uuid;\n\npub fn ws_scope() -> Scope {\n  web::scope(\"/ws\")\n    //.service(establish_ws_connection)\n    .service(web::resource(\"/v1\").route(web::get().to(establish_ws_connection_v1)))\n    .service(web::resource(\"/v2/{workspace_id}\").route(web::get().to(establish_ws_connection_v2)))\n}\nconst MAX_FRAME_SIZE: usize = 65_536; // 64 KiB\n\npub type RealtimeServerAddr = Addr<RealtimeServerActor>;\n\n/// This function will not be used after the 0.5.0 of the client.\n#[instrument(skip_all, err)]\n#[get(\"/{token}/{device_id}\")]\npub async fn establish_ws_connection(\n  request: HttpRequest,\n  payload: Payload,\n  path: Path<(String, String)>,\n  state: Data<AppState>,\n  jwt_secret: Data<Secret<String>>,\n  server: Data<RealtimeServerAddr>,\n) -> Result<HttpResponse> {\n  let (access_token, device_id) = path.into_inner();\n  let client_version = Version::new(0, 5, 0);\n  let connect_at = chrono::Utc::now().timestamp();\n  start_connect(\n    &request,\n    payload,\n    &state,\n    &jwt_secret,\n    server,\n    access_token,\n    device_id,\n    client_version,\n    connect_at,\n  )\n  .await\n}\n\n#[instrument(skip_all, err)]\npub async fn establish_ws_connection_v1(\n  request: HttpRequest,\n  payload: Payload,\n  state: Data<AppState>,\n  jwt_secret: Data<Secret<String>>,\n  server: Data<RealtimeServerAddr>,\n  web::Query(query_params): web::Query<HashMap<String, String>>,\n) -> Result<HttpResponse> {\n  // Try to parse the connect info from the request body\n  // If it fails, try to parse it from the query params\n  let ConnectInfo {\n    access_token,\n    client_version,\n    device_id,\n    connect_at,\n  } = match ConnectInfo::parse_from(&request) {\n    Ok(info) => info,\n    Err(_) => {\n      trace!(\"Failed to parse connect info from request body. Trying to parse from query params.\");\n      ConnectInfo::parse_from(&query_params)?\n    },\n  };\n\n  if client_version < state.config.websocket.min_client_version {\n    return Err(AppError::Connect(\"Client version is too low\".to_string()).into());\n  }\n\n  start_connect(\n    &request,\n    payload,\n    &state,\n    &jwt_secret,\n    server,\n    access_token,\n    device_id,\n    client_version,\n    connect_at,\n  )\n  .await\n}\n\n#[instrument(skip_all, err)]\npub async fn establish_ws_connection_v2(\n  request: HttpRequest,\n  payload: Payload,\n  path: Path<Uuid>,\n  state: Data<AppState>,\n  jwt_secret: Data<Secret<String>>,\n) -> Result<HttpResponse> {\n  let workspace_id = path.into_inner();\n  let ws_server = state.ws_server.clone();\n  let params = WsConnectionV2Params::parse(&request)?;\n  let auth = authorization_from_token(params.access_token.as_str(), &jwt_secret)?;\n  let user_uuid = UserUuid::from_auth(auth)?;\n  let uid = state.user_cache.get_user_uid(&user_uuid).await?;\n  let info = SessionInfo::new(\n    params.client_id,\n    uid,\n    params.device_id,\n    params.last_message_id,\n  );\n  tracing::debug!(\n    \"accepting new session {} (client id: {}) for workspace: {}\",\n    info.collab_origin(),\n    params.client_id,\n    workspace_id\n  );\n\n  let (tx, rx) = mpsc::channel(10);\n  let mut user_change_recv = state.pg_listeners.subscribe_user_change(uid);\n  actix::spawn(async move {\n    while let Some(notification) = user_change_recv.recv().await {\n      if let Some(user) = notification.payload {\n        let _ = tx\n          .send(ServerMessage::Notification {\n            notification: WorkspaceNotification::UserProfileChange {\n              uid: user.uid,\n              name: user.name,\n              email: user.email,\n            },\n          })\n          .await;\n      }\n    }\n  });\n\n  ws::WsResponseBuilder::new(\n    WsSession::new(workspace_id, info, ws_server, rx),\n    &request,\n    payload,\n  )\n  .frame_size(max_sync_message_size())\n  .start()\n}\n\n#[allow(clippy::too_many_arguments)]\n#[inline]\nasync fn start_connect(\n  request: &HttpRequest,\n  payload: Payload,\n  state: &Data<AppState>,\n  jwt_secret: &Data<Secret<String>>,\n  server: Data<RealtimeServerAddr>,\n  access_token: String,\n  device_id: String,\n  client_app_version: Version,\n  connect_at: i64,\n) -> Result<HttpResponse> {\n  let auth = authorization_from_token(access_token.as_str(), jwt_secret)?;\n  let user_uuid = UserUuid::from_auth(auth)?;\n  let result = state.user_cache.get_user_uid(&user_uuid).await;\n\n  match result {\n    Ok(uid) => {\n      debug!(\n        \"🚀new websocket connecting: uid={}, device_id={}, client_version:{}\",\n        uid, device_id, client_app_version\n      );\n\n      let session_id = uuid::Uuid::new_v4().to_string();\n      let realtime_user = RealtimeUser::new(\n        uid,\n        device_id,\n        session_id,\n        connect_at,\n        client_app_version.to_string(),\n      );\n      let (tx, external_source) = mpsc::channel(100);\n      let client = RealtimeClient::new(\n        realtime_user,\n        server.get_ref().clone(),\n        Duration::from_secs(state.config.websocket.heartbeat_interval as u64),\n        Duration::from_secs(state.config.websocket.client_timeout as u64),\n        client_app_version,\n        external_source,\n        10,\n      );\n\n      // Receive user change notifications and send them to the client.\n      listen_on_user_change(state, uid, tx);\n\n      match ws::WsResponseBuilder::new(client, request, payload)\n        .frame_size(MAX_FRAME_SIZE * 2)\n        .start()\n      {\n        Ok(response) => {\n          trace!(\"🔵ws connection established: uid={}\", uid);\n          Ok(response)\n        },\n        Err(e) => {\n          error!(\"🔴ws connection error: {:?}\", e);\n          Err(e)\n        },\n      }\n    },\n    Err(err) => {\n      if err.is_record_not_found() {\n        return Ok(HttpResponse::NotFound().json(\"user not found\"));\n      }\n      Err(AppResponseError::from(err).into())\n    },\n  }\n}\n\nfn listen_on_user_change(state: &Data<AppState>, uid: i64, tx: Sender<RealtimeMessage>) {\n  let mut user_change_recv = state.pg_listeners.subscribe_user_change(uid);\n  actix::spawn(async move {\n    while let Some(notification) = user_change_recv.recv().await {\n      // Extract the user object from the notification payload.\n      if let Some(user) = notification.payload {\n        trace!(\"Receive user change: {:?}\", user);\n        // Since bincode serialization is used for RealtimeMessage but does not support the\n        // Serde `deserialize_any` method, the user metadata is serialized into a JSON string.\n        // This step ensures compatibility and flexibility for the metadata field.\n        let metadata = serde_json::to_string(&user.metadata).ok();\n        // Construct a UserMessage with the user's details, including the serialized metadata.\n        let msg = UserMessage::ProfileChange(AFUserChange {\n          uid: user.uid,\n          name: user.name,\n          email: user.email,\n          metadata,\n        });\n        if tx.send(RealtimeMessage::User(msg)).await.is_err() {\n          break;\n        }\n      }\n    }\n  });\n}\n\nstruct ConnectInfo {\n  access_token: String,\n  client_version: Version,\n  device_id: String,\n  connect_at: i64,\n}\n\nconst CLIENT_VERSION: &str = \"client-version\";\nconst DEVICE_ID: &str = \"device-id\";\nconst CONNECT_AT: &str = \"connect-at\";\n\n// Trait for parameter extraction\ntrait ExtractParameter {\n  fn extract_param(&self, key: &str) -> Result<String, AppError>;\n}\n\n// Implement the trait for HashMap<String, String>\nimpl ExtractParameter for HashMap<String, String> {\n  fn extract_param(&self, key: &str) -> Result<String, AppError> {\n    self\n      .get(key)\n      .ok_or_else(|| {\n        AppError::InvalidRequest(format!(\"Parameter with given key:{} not found\", key))\n      })\n      .map(|s| s.to_string())\n  }\n}\n\n// Implement the trait for HttpRequest\nimpl ExtractParameter for HttpRequest {\n  fn extract_param(&self, key: &str) -> Result<String, AppError> {\n    self\n      .headers()\n      .get(key)\n      .ok_or_else(|| AppError::InvalidRequest(format!(\"Header with given key:{} not found\", key)))\n      .and_then(|value| {\n        value\n          .to_str()\n          .map_err(|_| {\n            AppError::InvalidRequest(format!(\"Invalid header value for given key:{}\", key))\n          })\n          .map(|s| s.to_string())\n      })\n  }\n}\n\nimpl ConnectInfo {\n  fn parse_from<T: ExtractParameter>(source: &T) -> Result<Self, AppError> {\n    let access_token = source.extract_param(AUTHORIZATION.as_str())?;\n    let client_version_str = source.extract_param(CLIENT_VERSION)?;\n    let client_version = Version::parse(&client_version_str)\n      .map_err(|_| AppError::InvalidRequest(format!(\"Invalid version:{}\", client_version_str)))?;\n    let device_id = source.extract_param(DEVICE_ID)?;\n    let connect_at = match source.extract_param(CONNECT_AT) {\n      Ok(start_at) => start_at\n        .parse::<i64>()\n        .unwrap_or_else(|_| chrono::Utc::now().timestamp()),\n      Err(_) => chrono::Utc::now().timestamp(),\n    };\n\n    Ok(Self {\n      access_token,\n      client_version,\n      device_id,\n      connect_at,\n    })\n  }\n}\n\nstruct WsConnectionV2Params {\n  access_token: String,\n  device_id: String,\n  client_id: u64,\n  last_message_id: Option<MessageId>,\n}\n\nimpl WsConnectionV2Params {\n  fn parse(req: &HttpRequest) -> Result<Self, AppError> {\n    let url = req.full_url();\n    let query = url.query_pairs().collect::<HashMap<_, _>>();\n\n    let access_token = Self::from_url(&query, \"token\")\n      .ok_or_else(|| AppError::InvalidRequest(\"Missing access token\".into()))?;\n    let device_id = Self::from_url(&query, \"deviceId\")\n      .ok_or_else(|| AppError::InvalidRequest(\"Missing device id\".into()))?;\n    let client_id: u64 = Self::from_url(&query, \"clientId\")\n      .and_then(|id| id.parse().ok())\n      .ok_or_else(|| AppError::InvalidRequest(\"Missing client id\".into()))?;\n    let last_message_id = Self::from_url(&query, \"lastMessageId\");\n    let last_message_id = match last_message_id {\n      None => None,\n      Some(message_id) => Some(MessageId::try_from(message_id).map_err(|_| {\n        AppError::InvalidRequest(\"Couldn't parse 'X-AF-Last-Message-ID' head value\".into())\n      })?),\n    };\n    Ok(WsConnectionV2Params {\n      access_token,\n      device_id,\n      client_id,\n      last_message_id,\n    })\n  }\n\n  fn from_url(url_params: &HashMap<Cow<str>, Cow<str>>, param: &str) -> Option<String> {\n    // we use params provided from URL as a backup since browser API doesn't allow to\n    // establish WebSocket connection with custom HTTP headers\n    let value = url_params.get(param).cloned()?;\n    Some(value.to_string())\n  }\n}\n"
  },
  {
    "path": "src/application.rs",
    "content": "use std::net::TcpListener;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse access_control::casbin::access::AccessControl;\nuse access_control::casbin::collab::{CollabAccessControlImpl, RealtimeCollabAccessControlImpl};\nuse access_control::casbin::workspace::WorkspaceAccessControlImpl;\nuse access_control::collab::{CollabAccessControl, RealtimeAccessControl};\nuse access_control::noops::collab::{\n  CollabAccessControlImpl as NoOpsCollabAccessControlImpl,\n  RealtimeCollabAccessControlImpl as NoOpsRealtimeCollabAccessControlImpl,\n};\nuse access_control::noops::workspace::WorkspaceAccessControlImpl as NoOpsWorkspaceAccessControlImpl;\nuse access_control::workspace::WorkspaceAccessControl;\nuse actix::{Actor, Supervisor};\n#[cfg(feature = \"use_actix_cors\")]\nuse actix_cors::Cors;\nuse actix_identity::IdentityMiddleware;\nuse actix_session::storage::RedisSessionStore;\nuse actix_session::SessionMiddleware;\nuse actix_web::cookie::Key;\nuse actix_web::middleware::NormalizePath;\nuse actix_web::{dev::Server, web, web::Data, App, HttpResponse, HttpServer, Responder};\nuse anyhow::{Context, Error};\nuse aws_sdk_s3::config::{Credentials, Region, SharedCredentialsProvider};\nuse aws_sdk_s3::operation::create_bucket::CreateBucketError;\nuse aws_sdk_s3::types::{\n  BucketInfo, BucketLocationConstraint, BucketType, CreateBucketConfiguration,\n};\nuse mailer::config::MailerSetting;\nuse secrecy::ExposeSecret;\nuse sqlx::{postgres::PgPoolOptions, PgPool};\nuse tokio::sync::RwLock;\nuse tracing::{error, info};\n\nuse appflowy_ai_client::client::AppFlowyAIClient;\nuse appflowy_collaborate::actix_ws::server::RealtimeServerActor;\nuse appflowy_collaborate::collab::cache::CollabCache;\nuse appflowy_collaborate::collab::collab_store::CollabStoreImpl;\nuse appflowy_collaborate::ws2::{CollabManager, WsServer};\nuse appflowy_collaborate::CollaborationServer;\nuse collab_stream::awareness_gossip::AwarenessGossip;\nuse collab_stream::metrics::CollabStreamMetrics;\nuse collab_stream::stream_router::{StreamRouter, StreamRouterOptions};\nuse database::file::s3_client_impl::{AwsS3BucketClientImpl, S3BucketStorage};\nuse indexer::collab_indexer::IndexerProvider;\nuse indexer::scheduler::{IndexerConfiguration, IndexerScheduler};\nuse indexer::vector::embedder::get_open_ai_config;\nuse infra::env_util::get_env_var;\nuse infra::thread_pool::ThreadPoolNoAbortBuilder;\nuse mailer::sender::Mailer;\nuse snowflake::Snowflake;\n\nuse crate::api::access_request::access_request_scope;\nuse crate::api::ai::ai_completion_scope;\nuse crate::api::chat::chat_scope;\nuse crate::api::data_import::data_import_scope;\nuse crate::api::file_storage::file_storage_scope;\nuse crate::api::guest::sharing_scope;\nuse crate::api::invite_code::invite_code_scope;\nuse crate::api::metrics::metrics_scope;\nuse crate::api::search::search_scope;\nuse crate::api::server_info::server_info_scope;\nuse crate::api::template::template_scope;\nuse crate::api::user::user_scope;\nuse crate::api::workspace::{collab_scope, workspace_scope};\nuse crate::api::ws::ws_scope;\nuse crate::biz::notification::email::EmailNotificationWorker;\nuse crate::biz::pg_listener::PgListeners;\nuse crate::biz::workspace::publish::{\n  PublishedCollabPostgresStore, PublishedCollabS3StoreWithPostgresFallback, PublishedCollabStore,\n};\nuse crate::config::config::{\n  Config, DatabaseSetting, GoTrueSetting, PublishedCollabStorageBackend, S3Setting,\n};\nuse crate::mailer::AFCloudMailer;\nuse crate::middleware::metrics_mw::MetricsMiddleware;\nuse crate::middleware::request_id::RequestIdMiddleware;\nuse crate::state::{AppMetrics, AppState, GoTrueAdmin, UserCache};\n\npub struct Application {\n  port: u16,\n  actix_server: Server,\n}\n\nimpl Application {\n  pub async fn build(config: Config, state: AppState) -> Result<Self, Error> {\n    let address = format!(\"{}:{}\", config.application.host, config.application.port);\n    let listener = TcpListener::bind(&address)?;\n    let port = listener.local_addr().unwrap().port();\n    info!(\"Server started at {}\", listener.local_addr().unwrap());\n    let actix_server = run_actix_server(listener, state, config).await?;\n\n    Ok(Self { port, actix_server })\n  }\n\n  pub async fn run_until_stopped(self) -> Result<(), std::io::Error> {\n    self.actix_server.await\n  }\n\n  pub fn port(&self) -> u16 {\n    self.port\n  }\n}\n\npub async fn run_actix_server(\n  listener: TcpListener,\n  state: AppState,\n  config: Config,\n) -> Result<Server, Error> {\n  let redis_store = RedisSessionStore::new(config.redis_uri.expose_secret())\n    .await\n    .map_err(|e| {\n      anyhow::anyhow!(\n        \"Failed to connect to Redis at {:?}: {:?}\",\n        config.redis_uri,\n        e\n      )\n    })?;\n\n  let storage = state.collab_storage.clone();\n\n  // Initialize metrics that which are registered in the registry.\n  let realtime_server = CollaborationServer::new(\n    storage.clone(),\n    state.realtime_access_control.clone(),\n    state.metrics.realtime_metrics.clone(),\n    state.redis_stream_router.clone(),\n    state.awareness_gossip.clone(),\n    state.redis_connection_manager.clone(),\n    Duration::from_secs(config.collab.group_persistence_interval_secs),\n    state.indexer_scheduler.clone(),\n  )\n  .await\n  .unwrap();\n\n  let realtime_server_actor = Supervisor::start(|_| RealtimeServerActor(realtime_server));\n  let mut server = HttpServer::new(move || {\n    let app = App::new()\n      .wrap(NormalizePath::trim())\n       // Middleware is registered for each App, scope, or Resource and executed in opposite order as registration\n      .wrap(MetricsMiddleware)\n      .wrap(IdentityMiddleware::default())\n      .wrap(\n        SessionMiddleware::builder(redis_store.clone(), Key::generate())\n          .build(),\n      )\n      .wrap(RequestIdMiddleware);\n\n    #[cfg(feature = \"use_actix_cors\")]\n    let app = app.wrap(actix_cors_scope());\n\n    app\n      .service(server_info_scope())\n      .service(user_scope())\n      .service(workspace_scope())\n      .service(invite_code_scope())\n      .service(collab_scope())\n      .service(ws_scope())\n      .service(file_storage_scope())\n      .service(chat_scope())\n      .service(ai_completion_scope())\n      .service(metrics_scope())\n      .service(search_scope())\n      .service(template_scope())\n      .service(data_import_scope())\n      .service(access_request_scope())\n      .service(sharing_scope())\n      .route(\"/health\", web::get().to(health_check))\n      .app_data(Data::new(state.metrics.registry.clone()))\n      .app_data(Data::new(state.metrics.request_metrics.clone()))\n      .app_data(Data::new(state.metrics.realtime_metrics.clone()))\n      .app_data(Data::new(state.metrics.access_control_metrics.clone()))\n      .app_data(Data::new(realtime_server_actor.clone()))\n      .app_data(Data::new(state.config.gotrue.jwt_secret.clone()))\n      .app_data(Data::new(state.clone()))\n      .app_data(Data::new(storage.clone()))\n      .app_data(Data::new(state.published_collab_store.clone()))\n  });\n\n  server = server.listen(listener)?;\n\n  Ok(server.run())\n}\n\npub async fn init_state(config: &Config) -> Result<AppState, Error> {\n  // Print the feature flags\n\n  let metrics = AppMetrics::new();\n\n  // Postgres\n  info!(\"Preparing to run database migrations...\");\n  let pg_pool = get_connection_pool(&config.db_settings).await?;\n  migrate(&pg_pool).await?;\n\n  // Bucket storage\n  info!(\"Setting up S3 bucket...\");\n  let s3_client = AwsS3BucketClientImpl::new(\n    get_aws_s3_client(&config.s3).await?,\n    config.s3.bucket.clone(),\n    config.s3.minio_url.clone(),\n    config.s3.presigned_url_endpoint.clone(),\n  );\n  let bucket_storage = Arc::new(S3BucketStorage::from_bucket_impl(\n    s3_client.clone(),\n    pg_pool.clone(),\n  ));\n\n  // Published Collab Storage\n  info!(\"Setting up Published Collab storage...\");\n  let published_collab_store: Arc<dyn PublishedCollabStore> =\n    match config.published_collab.storage_backend {\n      PublishedCollabStorageBackend::Postgres => {\n        info!(\"Using Postgres as the Published Collab storage backend ...\");\n        Arc::new(PublishedCollabPostgresStore::new(\n          metrics.published_collab_metrics.clone(),\n          pg_pool.clone(),\n        ))\n      },\n      PublishedCollabStorageBackend::S3WithPostgresBackup => {\n        info!(\"Using S3 as the Published Collab storage backend with Postgres as the backup ...\");\n        Arc::new(PublishedCollabS3StoreWithPostgresFallback::new(\n          metrics.published_collab_metrics.clone(),\n          pg_pool.clone(),\n          s3_client.clone(),\n        ))\n      },\n    };\n\n  // Gotrue\n  info!(\"Connecting to GoTrue...\");\n  let gotrue_client = get_gotrue_client(&config.gotrue).await?;\n  let gotrue_admin = get_admin_client(gotrue_client.clone(), &config.gotrue);\n\n  // Redis\n  info!(\"Connecting to Redis...\");\n  let (redis_conn_manager, redis_stream_router, awareness_gossip) = get_redis_client(\n    config.redis_uri.expose_secret(),\n    config.redis_worker_count,\n    metrics.collab_stream_metrics.clone(),\n  )\n  .await?;\n\n  info!(\"Setup AppFlowy AI: {}\", config.appflowy_ai.url());\n  let appflowy_ai_client = AppFlowyAIClient::new(&config.appflowy_ai.url());\n  // Pg listeners\n  info!(\"Setting up Pg listeners...\");\n  let pg_listeners = Arc::new(PgListeners::new(&pg_pool).await?);\n  // let collab_member_listener = pg_listeners.subscribe_collab_member_change();\n\n  let use_redis_ac_cache = get_env_var(\"APPFLOWY_ACCESS_CONTROL_REDIS_CACHE_ENABLED\", \"false\")\n    .parse::<bool>()\n    .unwrap_or(false);\n\n  info!(\n    \"Setting up access controls, is_enable: {}, use redis cache: {}\",\n    &config.access_control.is_enabled, use_redis_ac_cache,\n  );\n\n  let redis_uri = if use_redis_ac_cache {\n    Some(config.redis_uri.expose_secret().as_str())\n  } else {\n    None\n  };\n\n  let access_control = AccessControl::new(\n    pg_pool.clone(),\n    redis_uri,\n    metrics.access_control_metrics.clone(),\n  )\n  .await?;\n\n  let user_cache = UserCache::new(pg_pool.clone()).await;\n  let collab_access_control: Arc<dyn CollabAccessControl> =\n    if config.access_control.is_enabled && config.access_control.enable_collab_access_control {\n      Arc::new(CollabAccessControlImpl::new(access_control.clone()))\n    } else {\n      Arc::new(NoOpsCollabAccessControlImpl::new())\n    };\n  let workspace_access_control: Arc<dyn WorkspaceAccessControl> =\n    if config.access_control.is_enabled && config.access_control.enable_workspace_access_control {\n      Arc::new(WorkspaceAccessControlImpl::new(access_control.clone()))\n    } else {\n      Arc::new(NoOpsWorkspaceAccessControlImpl::new())\n    };\n\n  // thread pool\n  let thread_pool = Arc::new(\n    ThreadPoolNoAbortBuilder::new()\n      .thread_name(|idx| format!(\"af-collab-worker-{}\", idx))\n      .num_threads(4)\n      .build()\n      .expect(\"Failed to create collab thread pool\"),\n  );\n\n  let realtime_access_control: Arc<dyn RealtimeAccessControl> =\n    if config.access_control.is_enabled && config.access_control.enable_realtime_access_control {\n      Arc::new(RealtimeCollabAccessControlImpl::new(access_control))\n    } else {\n      Arc::new(NoOpsRealtimeCollabAccessControlImpl::new())\n    };\n  let collab_cache = CollabCache::new(\n    thread_pool.clone(),\n    redis_conn_manager.clone(),\n    pg_pool.clone(),\n    s3_client.clone(),\n    metrics.collab_metrics.clone(),\n    config.collab.s3_collab_threshold as usize,\n  );\n\n  let collab_access_control_storage = Arc::new(CollabStoreImpl::new(\n    collab_cache.clone(),\n    collab_access_control.clone(),\n    workspace_access_control.clone(),\n  ));\n\n  let mailer = get_mailer(&config.mailer).await?;\n  if config.notification.enable_email_notification {\n    info!(\"Setting up background notification worker...\");\n    let email_notification_interval = config.notification.email_notification_interval_secs;\n    let email_notification_grace_period = config.notification.email_notification_grace_period_secs;\n    let task_appflowy_web_url = config.appflowy_web_url.clone();\n    let task_mailer = mailer.clone();\n    let task_pg_pool = pg_pool.clone();\n    tokio::spawn(async move {\n      let email_notification_worker = EmailNotificationWorker::new(\n        task_pg_pool,\n        task_mailer,\n        email_notification_interval,\n        email_notification_grace_period,\n        &task_appflowy_web_url,\n      );\n      email_notification_worker.start_task().await;\n    });\n  }\n\n  info!(\"Setting up Indexer scheduler...\");\n  let (open_ai_config, azure_ai_config) = get_open_ai_config();\n  let embedder_config = IndexerConfiguration {\n    enable: get_env_var(\"APPFLOWY_INDEXER_ENABLED\", \"true\")\n      .parse::<bool>()\n      .unwrap_or(true),\n    open_ai_config,\n    azure_ai_config,\n    embedding_buffer_size: appflowy_collaborate::config::get_env_var(\n      \"APPFLOWY_INDEXER_EMBEDDING_BUFFER_SIZE\",\n      \"5000\",\n    )\n    .parse::<usize>()\n    .unwrap_or(5000),\n  };\n  let indexer_scheduler = IndexerScheduler::new(\n    IndexerProvider::new(),\n    pg_pool.clone(),\n    collab_access_control_storage.clone(),\n    metrics.embedding_metrics.clone(),\n    embedder_config,\n    redis_conn_manager.clone(),\n  );\n  let manager = CollabManager::new(\n    thread_pool.clone(),\n    collab_access_control.clone(),\n    collab_cache.clone(),\n    redis_conn_manager.clone(),\n    redis_stream_router.clone(),\n    awareness_gossip.clone(),\n    indexer_scheduler.clone(),\n  );\n  let ws_server = WsServer::new(manager).start();\n\n  info!(\"Application state initialized\");\n  Ok(AppState {\n    pg_pool,\n    config: Arc::new(config.clone()),\n    user_cache,\n    id_gen: Arc::new(RwLock::new(Snowflake::new(1))),\n    gotrue_client,\n    redis_stream_router,\n    awareness_gossip,\n    redis_connection_manager: redis_conn_manager,\n    collab_cache,\n    collab_storage: collab_access_control_storage,\n    collab_access_control,\n    workspace_access_control,\n    realtime_access_control,\n    bucket_storage,\n    published_collab_store,\n    bucket_client: s3_client,\n    pg_listeners,\n    metrics,\n    gotrue_admin,\n    mailer,\n    ai_client: appflowy_ai_client,\n    indexer_scheduler,\n    ws_server,\n  })\n}\n\nfn get_admin_client(\n  gotrue_client: gotrue::api::Client,\n  gotrue_setting: &GoTrueSetting,\n) -> GoTrueAdmin {\n  GoTrueAdmin::new(\n    gotrue_setting.jwt_secret.expose_secret().to_owned(),\n    gotrue_setting.service_role.clone(),\n    gotrue_client.clone(),\n  )\n}\n\nasync fn get_redis_client(\n  redis_uri: &str,\n  worker_count: usize,\n  metrics: Arc<CollabStreamMetrics>,\n) -> Result<\n  (\n    redis::aio::ConnectionManager,\n    Arc<StreamRouter>,\n    Arc<AwarenessGossip>,\n  ),\n  Error,\n> {\n  info!(\"Connecting to redis with uri: {}\", redis_uri);\n  let client = redis::Client::open(redis_uri).context(\"failed to connect to redis\")?;\n\n  let awareness_gossip = AwarenessGossip::new(&client).await?;\n  let router = StreamRouter::with_options(\n    &client,\n    metrics,\n    StreamRouterOptions {\n      worker_count,\n      xread_streams: 100,\n      xread_block_millis: Some(5000),\n      xread_count: None,\n    },\n  )?;\n\n  let manager = client\n    .get_connection_manager()\n    .await\n    .context(\"failed to get the connection manager\")?;\n  Ok((manager, router.into(), awareness_gossip.into()))\n}\n\npub async fn get_aws_s3_client(s3_setting: &S3Setting) -> Result<aws_sdk_s3::Client, Error> {\n  let credentials = Credentials::new(\n    s3_setting.access_key.clone(),\n    s3_setting.secret_key.expose_secret().clone(),\n    None,\n    None,\n    \"custom\",\n  );\n  let shared_credentials = SharedCredentialsProvider::new(credentials);\n\n  // Configure the AWS SDK\n  let config_builder = aws_sdk_s3::Config::builder()\n    .credentials_provider(shared_credentials)\n    .force_path_style(true)\n    .region(Region::new(s3_setting.region.clone()));\n\n  let config = if s3_setting.use_minio {\n    config_builder.endpoint_url(&s3_setting.minio_url).build()\n  } else {\n    config_builder.build()\n  };\n  let client = aws_sdk_s3::Client::from_conf(config);\n  if s3_setting.create_bucket {\n    create_bucket_if_not_exists(&client, s3_setting).await?;\n  } else {\n    info!(\"Skipping bucket creation, assumed to be created externally\");\n  }\n  Ok(client)\n}\n\nasync fn create_bucket_if_not_exists(\n  client: &aws_sdk_s3::Client,\n  s3_setting: &S3Setting,\n) -> Result<(), Error> {\n  let bucket_cfg = if s3_setting.use_minio {\n    CreateBucketConfiguration::builder()\n      .bucket(BucketInfo::builder().r#type(BucketType::Directory).build())\n      .build()\n  } else {\n    CreateBucketConfiguration::builder()\n      .location_constraint(BucketLocationConstraint::from(s3_setting.region.as_str()))\n      .build()\n  };\n\n  match client\n    .create_bucket()\n    .bucket(&s3_setting.bucket)\n    .create_bucket_configuration(bucket_cfg)\n    .send()\n    .await\n  {\n    Ok(_) => {\n      info!(\n        \"bucket created successfully: {}, region: {}\",\n        s3_setting.bucket, s3_setting.region\n      );\n      Ok(())\n    },\n    Err(err) => {\n      if let Some(service_error) = err.as_service_error() {\n        match service_error {\n          CreateBucketError::BucketAlreadyOwnedByYou(_)\n          | CreateBucketError::BucketAlreadyExists(_) => {\n            info!(\"Bucket already exists\");\n            Ok(())\n          },\n          _ => {\n            error!(\"Unhandle s3 service error: {:?}\", err);\n            Err(err.into())\n          },\n        }\n      } else {\n        error!(\"Failed to create bucket: {:?}\", err);\n        Err(err.into())\n      }\n    },\n  }\n}\n\nasync fn get_mailer(mailer: &MailerSetting) -> Result<AFCloudMailer, Error> {\n  info!(\"Connecting to mailer with setting: {:?}\", mailer);\n  let mailer = Mailer::new(\n    mailer.smtp_username.clone(),\n    mailer.smtp_email.clone(),\n    mailer.smtp_password.clone(),\n    &mailer.smtp_host,\n    mailer.smtp_port,\n    mailer.smtp_tls_kind.as_str(),\n  )\n  .await?;\n\n  AFCloudMailer::new(mailer).await\n}\n\nasync fn get_connection_pool(setting: &DatabaseSetting) -> Result<PgPool, Error> {\n  info!(\"Connecting to postgres database with setting: {}\", setting);\n  PgPoolOptions::new()\n    .max_connections(setting.max_connections)\n    .acquire_timeout(Duration::from_secs(10))\n    .max_lifetime(Duration::from_secs(30 * 60))\n    .idle_timeout(Duration::from_secs(30))\n    .connect_with(setting.pg_connect_options())\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to connect to postgres database: {}\", e))\n}\n\nasync fn migrate(pool: &PgPool) -> Result<(), Error> {\n  sqlx::migrate!(\"./migrations\")\n    .set_ignore_missing(true)\n    .run(pool)\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to run migrations: {}\", e))\n}\n\nasync fn get_gotrue_client(setting: &GoTrueSetting) -> Result<gotrue::api::Client, Error> {\n  info!(\"Connecting to GoTrue with setting: {:?}\", setting);\n  let gotrue_client = gotrue::api::Client::new(reqwest::Client::new(), &setting.base_url);\n  let _ = gotrue_client\n    .health()\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to connect to GoTrue: {}\", e));\n  Ok(gotrue_client)\n}\n\nasync fn health_check() -> impl Responder {\n  HttpResponse::Ok().body(\"OK\")\n}\n\n#[cfg(feature = \"use_actix_cors\")]\nfn actix_cors_scope() -> actix_cors::Cors {\n  Cors::default()\n    .allowed_origin(&get_env_var(\n      \"APPFLOWY_CORS_ALLOWED_ORIGIN\",\n      \"http://localhost:3000\",\n    ))\n    .allowed_methods(vec![\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"])\n    .allowed_headers(vec![\n      \"Content-Type\",\n      \"Authorization\",\n      \"Accept\",\n      \"Client-Version\",\n      \"Device-Id\",\n      \"X-Request-Id\",\n    ])\n    .max_age(3600)\n}\n"
  },
  {
    "path": "src/biz/access_request/mod.rs",
    "content": "pub mod ops;\n"
  },
  {
    "path": "src/biz/access_request/ops.rs",
    "content": "use std::ops::DerefMut;\nuse std::sync::Arc;\n\nuse crate::mailer::AFCloudMailer;\nuse crate::{\n  biz::collab::folder_view::{to_dto_view_icon, to_dto_view_layout},\n  mailer::{WorkspaceAccessRequestApprovedMailerParam, WorkspaceAccessRequestMailerParam},\n};\nuse access_control::workspace::WorkspaceAccessControl;\nuse anyhow::Context;\nuse app_error::AppError;\nuse appflowy_collaborate::ws2::WorkspaceCollabInstanceCache;\nuse database::{\n  access_request::{\n    insert_new_access_request, select_access_request_by_request_id, update_access_request_status,\n  },\n  pg_row::AFAccessRequestStatusColumn,\n  workspace::upsert_workspace_member_with_txn,\n};\nuse database_entity::dto::AFRole;\nuse shared_entity::dto::access_request_dto::{AccessRequest, AccessRequestView};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\npub async fn create_access_request(\n  pg_pool: &PgPool,\n  mailer: AFCloudMailer,\n  appflowy_web_url: &str,\n  workspace_id: Uuid,\n  view_id: Uuid,\n  uid: i64,\n) -> Result<Uuid, AppError> {\n  let request_id = insert_new_access_request(pg_pool, workspace_id, view_id, uid).await?;\n  let access_request = select_access_request_by_request_id(pg_pool, request_id).await?;\n  let cloned_mailer = mailer.clone();\n  let approve_url = format!(\n    \"{}/app/approve-request?request_id={}\",\n    appflowy_web_url, request_id\n  );\n  let email = access_request.workspace.owner_email.clone();\n  let recipient_name = access_request.workspace.owner_name.clone();\n  // use default icon until we have workspace icon\n  let workspace_icon_url =\n    \"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png\".to_string();\n  let user_icon_url =\n    \"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png\"\n      .to_string();\n  tokio::spawn(async move {\n    if let Err(err) = cloned_mailer\n      .send_workspace_access_request(\n        &recipient_name,\n        &email,\n        WorkspaceAccessRequestMailerParam {\n          user_icon_url,\n          username: access_request.requester.name,\n          workspace_name: access_request.workspace.workspace_name,\n          workspace_icon_url,\n          workspace_member_count: access_request.workspace.member_count.unwrap_or(0),\n          approve_url,\n        },\n      )\n      .await\n    {\n      tracing::error!(\"Failed to send access request email: {:?}\", err);\n    };\n  });\n  Ok(request_id)\n}\n\npub async fn get_access_request(\n  pg_pool: &PgPool,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  access_request_id: Uuid,\n  user_uid: i64,\n) -> Result<AccessRequest, AppError> {\n  let access_request_with_view_id =\n    select_access_request_by_request_id(pg_pool, access_request_id).await?;\n\n  if access_request_with_view_id.workspace.owner_uid != user_uid {\n    return Err(AppError::NotEnoughPermissions);\n  }\n  let folder = collab_instance_cache\n    .get_folder(access_request_with_view_id.workspace.workspace_id)\n    .await?;\n  let view = folder.get_view(&access_request_with_view_id.view_id.to_string(), user_uid);\n  let access_request_view = view\n    .map(|v| AccessRequestView {\n      view_id: v.id.clone(),\n      name: v.name.clone(),\n      icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())),\n      layout: to_dto_view_layout(&v.layout),\n    })\n    .ok_or(AppError::MissingView(format!(\n      \"the view {} is missing\",\n      access_request_with_view_id.view_id\n    )))?;\n  let access_request = AccessRequest {\n    request_id: access_request_with_view_id.request_id,\n    workspace: access_request_with_view_id.workspace,\n    requester: access_request_with_view_id.requester,\n    view: access_request_view,\n    status: access_request_with_view_id.status,\n    created_at: access_request_with_view_id.created_at,\n  };\n  Ok(access_request)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn approve_or_reject_access_request(\n  pg_pool: &PgPool,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  mailer: AFCloudMailer,\n  appflowy_web_url: &str,\n  request_id: Uuid,\n  uid: i64,\n  is_approved: bool,\n) -> Result<(), AppError> {\n  let access_request = select_access_request_by_request_id(pg_pool, request_id).await?;\n  workspace_access_control\n    .enforce_role_strong(&uid, &access_request.workspace.workspace_id, AFRole::Owner)\n    .await?;\n\n  let mut txn = pg_pool.begin().await.context(\"approving request\")?;\n  let role = AFRole::Member;\n  if is_approved {\n    upsert_workspace_member_with_txn(\n      &mut txn,\n      &access_request.workspace.workspace_id,\n      &access_request.requester.email,\n      role.clone(),\n    )\n    .await?;\n    workspace_access_control\n      .insert_role(\n        &access_request.requester.uid,\n        &access_request.workspace.workspace_id,\n        role.clone(),\n      )\n      .await?;\n    let cloned_mailer = mailer.clone();\n    let launch_workspace_url = format!(\n      \"{}/app/{}\",\n      appflowy_web_url, &access_request.workspace.workspace_id\n    );\n\n    // use default icon until we have workspace icon\n    let workspace_icon_url =\n      \"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png\".to_string();\n    tokio::spawn(async move {\n      if let Err(err) = cloned_mailer\n        .send_workspace_access_request_approval_notification(\n          &access_request.requester.name,\n          &access_request.requester.email,\n          WorkspaceAccessRequestApprovedMailerParam {\n            workspace_name: access_request.workspace.workspace_name,\n            workspace_icon_url,\n            workspace_member_count: access_request.workspace.member_count.unwrap_or(0),\n            launch_workspace_url,\n          },\n        )\n        .await\n      {\n        tracing::error!(\n          \"Failed to send access request approved notification email: {:?}\",\n          err\n        );\n      };\n    });\n  }\n  let status = if is_approved {\n    AFAccessRequestStatusColumn::Approved\n  } else {\n    AFAccessRequestStatusColumn::Rejected\n  };\n  update_access_request_status(txn.deref_mut(), request_id, status).await?;\n  txn.commit().await.context(\"committing transaction\")?;\n  Ok(())\n}\n"
  },
  {
    "path": "src/biz/authentication/jwt.rs",
    "content": "use actix_http::Payload;\nuse actix_web::{web::Data, FromRequest, HttpRequest};\n\nuse gotrue_entity::gotrue_jwt::GoTrueJWTClaims;\nuse secrecy::{ExposeSecret, Secret};\nuse serde::{Deserialize, Serialize};\nuse sqlx::types::{uuid, Uuid};\nuse std::fmt::{Display, Formatter};\nuse std::ops::Deref;\nuse std::str::FromStr;\nuse tracing::instrument;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct UserUuid(uuid::Uuid);\n\nimpl UserUuid {\n  pub fn from_auth(auth: Authorization) -> Result<Self, actix_web::Error> {\n    Ok(Self(auth.uuid()?))\n  }\n}\n\nimpl Deref for UserUuid {\n  type Target = uuid::Uuid;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct UserToken(pub String);\nimpl UserToken {\n  pub fn from_auth(auth: Authorization) -> Result<Self, actix_web::Error> {\n    Ok(Self(auth.token))\n  }\n}\n\nimpl Display for UserToken {\n  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {\n    f.write_str(&self.0)\n  }\n}\n\nimpl FromRequest for UserUuid {\n  type Error = actix_web::Error;\n\n  type Future = std::future::Ready<Result<Self, Self::Error>>;\n\n  fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {\n    let auth = get_auth_from_request(req);\n    match auth {\n      Ok(auth) => match UserUuid::from_auth(auth) {\n        Ok(uuid) => std::future::ready(Ok(uuid)),\n        Err(e) => std::future::ready(Err(e)),\n      },\n      Err(e) => std::future::ready(Err(e)),\n    }\n  }\n}\n\n// For cases where the handler itself will handle the request differently\n// based on whether the user is authenticated or not\npub struct OptionalUserUuid(Option<UserUuid>);\n\nimpl FromRequest for OptionalUserUuid {\n  type Error = actix_web::Error;\n\n  type Future = std::future::Ready<Result<Self, Self::Error>>;\n\n  fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {\n    let auth = get_auth_from_request(req);\n    match auth {\n      Ok(auth) => match UserUuid::from_auth(auth) {\n        Ok(uuid) => std::future::ready(Ok(OptionalUserUuid(Some(uuid)))),\n        Err(_) => std::future::ready(Ok(OptionalUserUuid(None))),\n      },\n      Err(_) => std::future::ready(Ok(OptionalUserUuid(None))),\n    }\n  }\n}\n\nimpl OptionalUserUuid {\n  pub fn as_uuid(&self) -> Option<uuid::Uuid> {\n    self.0.as_deref().map(|uuid| uuid.to_owned())\n  }\n}\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct Authorization {\n  pub token: String,\n  pub claims: GoTrueJWTClaims,\n}\n\nimpl Authorization {\n  pub fn uuid(&self) -> Result<uuid::Uuid, actix_web::Error> {\n    let sub = self.claims.sub.as_deref();\n    match sub {\n      None => Err(actix_web::error::ErrorUnauthorized(\n        \"Invalid Authorization header, missing sub(uuid)\",\n      )),\n      Some(sub) => match Uuid::from_str(sub) {\n        Ok(uuid) => Ok(uuid),\n        Err(_) => Err(actix_web::error::ErrorUnauthorized(format!(\n          \"Invalid Authorization header, invalid sub: {}\",\n          sub\n        ))),\n      },\n    }\n  }\n}\n\nimpl FromRequest for Authorization {\n  type Error = actix_web::Error;\n\n  type Future = std::future::Ready<Result<Self, Self::Error>>;\n\n  fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {\n    let auth = get_auth_from_request(req);\n    match auth {\n      Ok(auth) => std::future::ready(Ok(auth)),\n      Err(e) => std::future::ready(Err(e)),\n    }\n  }\n}\n\nfn get_auth_from_request(req: &HttpRequest) -> Result<Authorization, actix_web::Error> {\n  let jwt_secret_data =\n    req\n      .app_data::<Data<Secret<String>>>()\n      .ok_or(actix_web::error::ErrorInternalServerError(\n        \"jwt secret not found\",\n      ))?;\n  let bearer = req\n    .headers()\n    .get(\"Authorization\")\n    .ok_or(actix_web::error::ErrorUnauthorized(\n      \"No Authorization header\",\n    ))?;\n\n  let bearer_str = bearer\n    .to_str()\n    .map_err(actix_web::error::ErrorUnauthorized)?;\n\n  let (_, token) = bearer_str\n    .split_once(\"Bearer \")\n    .ok_or(actix_web::error::ErrorUnauthorized(\n      \"Invalid Authorization header, missing Bearer\",\n    ))?;\n\n  authorization_from_token(token, jwt_secret_data)\n}\n\n#[instrument(level = \"trace\", skip_all, err)]\npub fn authorization_from_token(\n  token: &str,\n  jwt_secret: &Data<Secret<String>>,\n) -> Result<Authorization, actix_web::Error> {\n  let claims = gotrue_jwt_claims_from_token(token, jwt_secret)?;\n  Ok(Authorization {\n    token: token.to_string(),\n    claims,\n  })\n}\n\n#[instrument(level = \"trace\", skip_all, err)]\nfn gotrue_jwt_claims_from_token(\n  token: &str,\n  jwt_secret: &Data<Secret<String>>,\n) -> Result<GoTrueJWTClaims, actix_web::Error> {\n  let claims =\n    GoTrueJWTClaims::decode(token, jwt_secret.expose_secret().as_bytes()).map_err(|err| {\n      actix_web::error::ErrorUnauthorized(format!(\"fail to decode token, error:{}\", err))\n    })?;\n  Ok(claims)\n}\n"
  },
  {
    "path": "src/biz/authentication/mod.rs",
    "content": "pub mod jwt;\n"
  },
  {
    "path": "src/biz/chat/metrics.rs",
    "content": "use prometheus_client::{\n  encoding::EncodeLabelSet,\n  metrics::{counter::Counter, family::Family},\n};\n\n#[derive(Default, Clone)]\npub struct AIMetrics {\n  total_stream_count: Counter,\n  failed_stream_count: Counter,\n  stream_image_count: Counter,\n  total_completion_count: Counter,\n  total_summary_row_count: Counter,\n  total_translate_row_count: Counter,\n  prompt_usage_count: Family<PromptLabel, Counter>,\n}\n\nimpl AIMetrics {\n  pub fn register(registry: &mut prometheus_client::registry::Registry) -> Self {\n    let metrics = Self::default();\n    let realtime_registry = registry.sub_registry_with_prefix(\"ai\");\n\n    // Register each metric with the Prometheus registry\n    realtime_registry.register(\n      \"total_stream_count\",\n      \"Total count of streams processed\",\n      metrics.total_stream_count.clone(),\n    );\n    realtime_registry.register(\n      \"failed_stream_count\",\n      \"Total count of failed streams\",\n      metrics.failed_stream_count.clone(),\n    );\n    realtime_registry.register(\n      \"image_stream_count\",\n      \"Total count of image streams processed\",\n      metrics.stream_image_count.clone(),\n    );\n    realtime_registry.register(\n      \"total_completion_count\",\n      \"Total count of completions processed\",\n      metrics.total_completion_count.clone(),\n    );\n    realtime_registry.register(\n      \"total_summary_row_count\",\n      \"Total count of summary rows processed\",\n      metrics.total_summary_row_count.clone(),\n    );\n    realtime_registry.register(\n      \"total_translate_row_count\",\n      \"Total count of translation rows processed\",\n      metrics.total_translate_row_count.clone(),\n    );\n    realtime_registry.register(\n      \"prompt_usage_count\",\n      \"Prompt usage count by prompt id\",\n      metrics.prompt_usage_count.clone(),\n    );\n\n    metrics\n  }\n\n  pub fn record_total_stream_count(&self, count: u64) {\n    self.total_stream_count.inc_by(count);\n  }\n\n  pub fn record_failed_stream_count(&self, count: u64) {\n    self.failed_stream_count.inc_by(count);\n  }\n\n  pub fn record_stream_image_count(&self, count: u64) {\n    self.stream_image_count.inc_by(count);\n  }\n\n  pub fn record_total_completion_count(&self, count: u64) {\n    self.total_completion_count.inc_by(count);\n  }\n\n  pub fn record_total_summary_row_count(&self, count: u64) {\n    self.total_summary_row_count.inc_by(count);\n  }\n\n  pub fn record_total_translate_row_count(&self, count: u64) {\n    self.total_translate_row_count.inc_by(count);\n  }\n\n  pub fn record_prompt_usage_count(&self, prompt_id: &str, count: u64) {\n    self\n      .prompt_usage_count\n      .get_or_create(&PromptLabel {\n        prompt_id: prompt_id.to_string(),\n      })\n      .inc_by(count);\n  }\n}\n\n#[derive(Debug, Clone, Hash, PartialEq, Eq, EncodeLabelSet)]\npub struct PromptLabel {\n  pub prompt_id: String,\n}\n"
  },
  {
    "path": "src/biz/chat/mod.rs",
    "content": "pub mod metrics;\npub mod ops;\n"
  },
  {
    "path": "src/biz/chat/ops.rs",
    "content": "use anyhow::anyhow;\n\nuse app_error::AppError;\nuse appflowy_ai_client::client::AppFlowyAIClient;\nuse database::chat;\nuse database::chat::chat_ops::{\n  delete_answer_message_by_question_message_id, insert_answer_message,\n  insert_answer_message_with_transaction, insert_chat, insert_question_message,\n  select_chat_message_matching_reply_message_id, select_chat_messages,\n  select_chat_messages_with_author_uuid,\n};\nuse shared_entity::dto::chat_dto::{\n  ChatAuthor, ChatAuthorType, ChatAuthorWithUuid, ChatMessage, ChatMessageWithAuthorUuid,\n  CreateChatMessageParams, CreateChatParams, GetChatMessageParams, RepeatedChatMessage,\n  RepeatedChatMessageWithAuthorUuid, UpdateChatMessageContentParams,\n};\nuse sqlx::PgPool;\nuse tracing::{info, trace};\n\nuse uuid::Uuid;\nuse validator::Validate;\n\npub(crate) async fn create_chat(\n  pg_pool: &PgPool,\n  params: CreateChatParams,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  params.validate()?;\n  trace!(\"[Chat] create chat {:?}\", params);\n\n  insert_chat(pg_pool, workspace_id, params).await?;\n  Ok(())\n}\n\npub(crate) async fn delete_chat(pg_pool: &PgPool, chat_id: &str) -> Result<(), AppError> {\n  let mut txn = pg_pool.begin().await?;\n  chat::chat_ops::delete_chat(&mut txn, chat_id).await?;\n  txn.commit().await?;\n  Ok(())\n}\n\npub async fn update_chat_message(\n  workspace_id: String,\n  pg_pool: &PgPool,\n  params: UpdateChatMessageContentParams,\n  ai_client: AppFlowyAIClient,\n  ai_model: &str,\n) -> Result<(), AppError> {\n  let mut txn = pg_pool.begin().await?;\n  delete_answer_message_by_question_message_id(&mut txn, params.message_id).await?;\n  chat::chat_ops::update_chat_message_content(&mut txn, &params).await?;\n  txn.commit().await.map_err(|err| {\n    AppError::Internal(anyhow!(\n      \"Failed to commit transaction to update chat message: {}\",\n      err\n    ))\n  })?;\n\n  // TODO(nathan): query the metadata from the database\n  let new_answer = ai_client\n    .send_question(\n      &workspace_id,\n      &params.chat_id,\n      params.message_id,\n      &params.content,\n      ai_model,\n      None,\n    )\n    .await?;\n  let _answer = insert_answer_message(\n    pg_pool,\n    ChatAuthor::ai(),\n    &params.chat_id,\n    new_answer.content,\n    new_answer.metadata,\n    params.message_id,\n  )\n  .await?;\n\n  Ok(())\n}\n\npub async fn generate_chat_message_answer(\n  workspace_id: String,\n  pg_pool: &PgPool,\n  ai_client: AppFlowyAIClient,\n  question_message_id: i64,\n  chat_id: &str,\n  ai_model: &str,\n) -> Result<ChatMessage, AppError> {\n  let (content, metadata) =\n    chat::chat_ops::select_chat_message_content(pg_pool, question_message_id).await?;\n  let new_answer = ai_client\n    .send_question(\n      &workspace_id,\n      chat_id,\n      question_message_id,\n      &content,\n      ai_model,\n      Some(metadata),\n    )\n    .await\n    .map_err(|err| AppError::AIServiceUnavailable(err.to_string()))?;\n\n  info!(\"new_answer: {:?}\", new_answer);\n  // Save the answer to the database\n  let mut txn = pg_pool.begin().await?;\n  let message = insert_answer_message_with_transaction(\n    &mut txn,\n    ChatAuthor::ai(),\n    chat_id,\n    new_answer.content,\n    new_answer.metadata.unwrap_or_default(),\n    question_message_id,\n  )\n  .await?;\n  txn.commit().await.map_err(|err| {\n    AppError::Internal(anyhow!(\n      \"Failed to commit transaction to update chat message: {}\",\n      err\n    ))\n  })?;\n\n  Ok(message)\n}\n\npub async fn create_chat_message(\n  pg_pool: &PgPool,\n  uid: i64,\n  user_uuid: Uuid,\n  chat_id: String,\n  params: CreateChatMessageParams,\n) -> Result<ChatMessageWithAuthorUuid, AppError> {\n  let chat_id = chat_id.clone();\n  let pg_pool = pg_pool.clone();\n\n  let question = insert_question_message(\n    &pg_pool,\n    ChatAuthorWithUuid::new(uid, user_uuid, ChatAuthorType::Human),\n    &chat_id,\n    params.content,\n  )\n  .await?;\n  Ok(question)\n}\n\n// Deprecated since v0.9.24\npub async fn get_chat_messages(\n  pg_pool: &PgPool,\n  params: GetChatMessageParams,\n  chat_id: &str,\n) -> Result<RepeatedChatMessage, AppError> {\n  params.validate()?;\n\n  let mut txn = pg_pool.begin().await?;\n  let messages = select_chat_messages(&mut txn, chat_id, params).await?;\n  txn.commit().await?;\n  Ok(messages)\n}\n\npub async fn get_chat_messages_with_author_uuid(\n  pg_pool: &PgPool,\n  params: GetChatMessageParams,\n  chat_id: &str,\n) -> Result<RepeatedChatMessageWithAuthorUuid, AppError> {\n  params.validate()?;\n\n  let mut txn = pg_pool.begin().await?;\n  let messages = select_chat_messages_with_author_uuid(&mut txn, chat_id, params).await?;\n  txn.commit().await?;\n  Ok(messages)\n}\n\npub async fn get_question_message(\n  pg_pool: &PgPool,\n  chat_id: &str,\n  answer_message_id: i64,\n) -> Result<Option<ChatMessage>, AppError> {\n  let mut txn = pg_pool.begin().await?;\n  let message =\n    select_chat_message_matching_reply_message_id(&mut txn, chat_id, answer_message_id).await?;\n  txn.commit().await?;\n  Ok(message)\n}\n"
  },
  {
    "path": "src/biz/collab/database.rs",
    "content": "use std::sync::Arc;\n\nuse super::utils::batch_get_latest_collab_encoded;\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse collab::lock::RwLock;\nuse collab_database::database_trait::{DatabaseCollabReader, EncodeCollabByOid};\nuse collab_database::rows::{meta_id_from_row_id, DatabaseRow, RowId, RowMetaKey};\nuse collab_database::{\n  database::{gen_database_group_id, gen_field_id},\n  entity::FieldType,\n  error::DatabaseError,\n  fields::{\n    date_type_option::DateTypeOption, default_field_settings_for_fields,\n    select_type_option::SingleSelectTypeOption, Field, TypeOptionData,\n  },\n  views::{\n    BoardLayoutSetting, CalendarLayoutSetting, DatabaseLayout, FieldSettingsByFieldIdMap, Group,\n    GroupSetting, GroupSettingMap, LayoutSettings,\n  },\n};\nuse collab_entity::{CollabType, EncodedCollab};\nuse dashmap::DashMap;\nuse database::collab::{select_collab_id_exists, CollabStore, GetCollabOrigin};\nuse sqlx::PgPool;\nuse uuid::Uuid;\nuse yrs::block::ClientID;\n\npub struct LinkedViewDependencies {\n  pub layout_settings: LayoutSettings,\n  pub field_settings: FieldSettingsByFieldIdMap,\n  pub group_settings: Vec<GroupSettingMap>,\n  pub deps_fields: Vec<Field>,\n}\n\npub fn resolve_dependencies_when_create_database_linked_view(\n  database_layout: DatabaseLayout,\n  fields: &[Field],\n) -> Result<LinkedViewDependencies, AppError> {\n  match database_layout {\n    DatabaseLayout::Grid => resolve_grid_dependencies(fields),\n    DatabaseLayout::Board => resolve_board_dependencies(fields),\n    DatabaseLayout::Calendar => resolve_calendar_dependencies(fields),\n  }\n}\n\nfn resolve_grid_dependencies(fields: &[Field]) -> Result<LinkedViewDependencies, AppError> {\n  Ok(LinkedViewDependencies {\n    layout_settings: LayoutSettings::default(),\n    field_settings: default_field_settings_for_fields(fields, DatabaseLayout::Grid),\n    group_settings: vec![],\n    deps_fields: vec![],\n  })\n}\n\nfn resolve_board_dependencies(\n  original_fields: &[Field],\n) -> Result<LinkedViewDependencies, AppError> {\n  let database_layout = DatabaseLayout::Board;\n  let (group_field, all_fields, deps_fields) = match original_fields\n    .iter()\n    .find(|f| FieldType::from(f.field_type).can_be_group())\n  {\n    Some(field) => (field.clone(), original_fields.to_vec(), vec![]),\n    None => {\n      let card_status_field = create_card_status_field();\n      let mut fields = original_fields.to_vec();\n      fields.push(card_status_field.clone());\n      (card_status_field.clone(), fields, vec![card_status_field])\n    },\n  };\n  let field_settings = default_field_settings_for_fields(&all_fields, database_layout);\n  let group_ids = match FieldType::from(group_field.field_type) {\n    FieldType::SingleSelect => {\n      let mut group_ids = vec![group_field.id.clone()];\n      let single_select_type_option_ids = single_select_type_option_ids_from_field(&group_field)?;\n      group_ids.extend(single_select_type_option_ids);\n      Ok(group_ids)\n    },\n    FieldType::Checkbox => Ok(vec![\"Yes\".to_string(), \"No\".to_string()]),\n    field_type => Err(AppError::Internal(anyhow::anyhow!(format!(\n      \"invalid dep field type({}) for board layout\",\n      field_type\n    )))),\n  }?;\n\n  let groups = group_ids.iter().map(|id| Group::new(id.clone())).collect();\n  let group_settings: Vec<GroupSettingMap> = vec![GroupSetting {\n    id: gen_database_group_id(),\n    field_id: group_field.id.clone(),\n    field_type: group_field.field_type,\n    groups,\n    content: Default::default(),\n  }\n  .into()];\n\n  let mut layout_settings = LayoutSettings::default();\n  layout_settings.insert(database_layout, BoardLayoutSetting::new().into());\n  Ok(LinkedViewDependencies {\n    layout_settings,\n    field_settings,\n    group_settings,\n    deps_fields,\n  })\n}\n\nfn single_select_type_option_ids_from_field(field: &Field) -> Result<Vec<String>, AppError> {\n  let type_option_data: Option<&TypeOptionData> =\n    field.type_options.get(&FieldType::SingleSelect.to_string());\n  match type_option_data {\n    Some(type_option_data) => {\n      let single_select_type_option = SingleSelectTypeOption::from(type_option_data.to_owned());\n      let single_select_type_option_ids: Vec<String> = single_select_type_option\n        .options\n        .iter()\n        .map(|option| option.id.clone())\n        .collect();\n      Ok(single_select_type_option_ids)\n    },\n    None => Err(AppError::Internal(anyhow::anyhow!(\n      \"invalid field for single select type options\",\n    ))),\n  }\n}\n\nfn resolve_calendar_dependencies(fields: &[Field]) -> Result<LinkedViewDependencies, AppError> {\n  let database_layout = DatabaseLayout::Calendar;\n  let (date_time_field, all_fields, deps_fields) = match fields\n    .iter()\n    .find(|f| FieldType::from(f.field_type) == FieldType::DateTime)\n  {\n    Some(field) => {\n      let all_fields = fields.to_vec();\n      (field.clone(), all_fields, vec![])\n    },\n    None => {\n      let date_field = create_date_field();\n      let mut all_fields = fields.to_vec();\n      all_fields.push(date_field.clone());\n      (date_field.clone(), all_fields, vec![date_field])\n    },\n  };\n  let field_settings = default_field_settings_for_fields(&all_fields, database_layout);\n  let mut layout_settings = LayoutSettings::default();\n  let layout_setting = CalendarLayoutSetting::new(date_time_field.id.clone()).into();\n  layout_settings.insert(database_layout, layout_setting);\n  Ok(LinkedViewDependencies {\n    layout_settings,\n    field_settings,\n    group_settings: vec![],\n    deps_fields,\n  })\n}\n\nfn create_date_field() -> Field {\n  let field_type = FieldType::DateTime;\n  let default_date_type_option = DateTypeOption::default();\n  let field_id = gen_field_id();\n  Field::new(field_id, \"Date\".to_string(), field_type.into(), false)\n    .with_type_option_data(field_type, default_date_type_option.into())\n}\n\nfn create_card_status_field() -> Field {\n  let field_type = FieldType::SingleSelect;\n  let default_select_type_option = SingleSelectTypeOption::default();\n  let field_id = gen_field_id();\n  Field::new(field_id, \"Status\".to_string(), field_type.into(), false)\n    .with_type_option_data(field_type, default_select_type_option.into())\n}\n\npub async fn check_if_row_document_collab_exists(\n  pg_pool: &PgPool,\n  row_id: &Uuid,\n) -> Result<bool, AppError> {\n  let row_document_collab_id = meta_id_from_row_id(row_id, RowMetaKey::DocumentId);\n  let row_document_collab_id = Uuid::parse_str(&row_document_collab_id)\n    .map_err(|_| AppError::Internal(anyhow::anyhow!(\"Invalid row document collab ID\")))?;\n  let exists = select_collab_id_exists(pg_pool, &row_document_collab_id).await?;\n  Ok(exists)\n}\n\n#[derive(Clone)]\npub struct PostgresDatabaseCollabService {\n  pub workspace_id: Uuid,\n  pub collab_storage: Arc<dyn CollabStore>,\n  pub client_id: ClientID,\n  cache: Arc<DashMap<RowId, Arc<RwLock<DatabaseRow>>>>,\n}\n\nimpl PostgresDatabaseCollabService {\n  pub fn new(\n    workspace_id: Uuid,\n    collab_storage: Arc<dyn CollabStore>,\n    client_id: ClientID,\n  ) -> Self {\n    Self {\n      workspace_id,\n      collab_storage,\n      client_id,\n      cache: Arc::new(DashMap::new()),\n    }\n  }\n}\n\n#[async_trait]\nimpl DatabaseCollabReader for PostgresDatabaseCollabService {\n  async fn reader_client_id(&self) -> ClientID {\n    self.client_id\n  }\n\n  async fn reader_get_collab(\n    &self,\n    object_id: &str,\n    collab_type: CollabType,\n  ) -> Result<EncodedCollab, DatabaseError> {\n    let object_id = Uuid::parse_str(object_id)?;\n    let collab_data = self\n      .collab_storage\n      .get_full_encode_collab(\n        GetCollabOrigin::Server,\n        &self.workspace_id,\n        &object_id,\n        collab_type,\n      )\n      .await\n      .map(|v| v.encoded_collab)\n      .map_err(|err| DatabaseError::Internal(err.into()))?;\n\n    Ok(collab_data)\n  }\n\n  async fn reader_batch_get_collabs(\n    &self,\n    object_ids: Vec<String>,\n    collab_type: CollabType,\n  ) -> Result<EncodeCollabByOid, DatabaseError> {\n    let mut object_uuids = Vec::with_capacity(object_ids.len());\n    for object_id in object_ids {\n      object_uuids.push(Uuid::parse_str(&object_id)?);\n    }\n    let encoded_collabs = batch_get_latest_collab_encoded(\n      &self.collab_storage,\n      GetCollabOrigin::Server,\n      self.workspace_id,\n      &object_uuids,\n      collab_type,\n    )\n    .await\n    .unwrap()\n    .into_iter()\n    .map(|(k, v)| (k.to_string(), v))\n    .collect();\n    Ok(encoded_collabs)\n  }\n\n  fn database_row_cache(&self) -> Option<Arc<DashMap<RowId, Arc<RwLock<DatabaseRow>>>>> {\n    Some(self.cache.clone())\n  }\n}\n\n#[cfg(test)]\nmod tests {\n  use collab_database::{\n    fields::select_type_option::{SelectOption, SelectOptionColor},\n    views::LayoutSetting,\n  };\n\n  use super::*;\n\n  #[test]\n  fn test_resolve_dependencies_when_create_database_linked_view_grid() {\n    let database_layout = DatabaseLayout::Grid;\n    let fields: Vec<Field> = vec![];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert!(dependencies.deps_fields.is_empty());\n    let fields: Vec<Field> = vec![\n      Field::from_field_type(\"name\", FieldType::RichText, true),\n      Field::from_field_type(\"description\", FieldType::RichText, false),\n    ];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert!(dependencies.deps_fields.is_empty());\n  }\n\n  #[test]\n  fn test_resolve_dependencies_when_create_database_linked_view_board() {\n    let database_layout = DatabaseLayout::Board;\n    let fields: Vec<Field> = vec![];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert_eq!(dependencies.deps_fields.len(), 1);\n    let deps_field = dependencies.deps_fields[0].clone();\n    assert_eq!(deps_field.field_type, FieldType::SingleSelect as i64);\n    assert_eq!(dependencies.group_settings.len(), 1);\n    let group_setting_map: GroupSettingMap = dependencies.group_settings[0].clone();\n    let group_setting = GroupSetting::try_from(group_setting_map).unwrap();\n    assert_eq!(group_setting.groups.len(), 1);\n    assert_eq!(group_setting.groups[0].id, deps_field.id);\n\n    let select_option = SelectOption::with_color(\"Done\", SelectOptionColor::Purple);\n    let options = vec![select_option];\n    let card_status_option_ids: Vec<String> =\n      options.iter().map(|option| option.id.clone()).collect();\n    let mut card_status_options = SingleSelectTypeOption::default();\n    card_status_options.options.extend(options);\n    let mut card_status_field = Field::new(\n      gen_field_id(),\n      \"Status\".to_string(),\n      FieldType::SingleSelect.into(),\n      false,\n    );\n    card_status_field.type_options.insert(\n      FieldType::SingleSelect.to_string(),\n      card_status_options.into(),\n    );\n    let fields = vec![card_status_field.clone()];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert!(dependencies.deps_fields.is_empty());\n    assert_eq!(dependencies.group_settings.len(), 1);\n    let group_setting_map: GroupSettingMap = dependencies.group_settings[0].clone();\n    let group_setting = GroupSetting::try_from(group_setting_map).unwrap();\n    assert_eq!(group_setting.groups.len(), 2);\n    assert_eq!(group_setting.groups[0].id, card_status_field.id);\n    assert_eq!(group_setting.groups[1].id, card_status_option_ids[0]);\n  }\n\n  #[test]\n  fn test_resolve_dependencies_when_create_database_linked_view_calendar() {\n    let database_layout = DatabaseLayout::Calendar;\n    let fields: Vec<Field> = vec![];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert_eq!(dependencies.deps_fields.len(), 1);\n    assert_eq!(\n      dependencies.deps_fields[0].field_type,\n      FieldType::DateTime as i64\n    );\n    let date_field = Field::from_field_type(\"datetime\", FieldType::DateTime, false);\n    let fields: Vec<Field> = vec![\n      Field::from_field_type(\"title\", FieldType::RichText, true),\n      date_field.clone(),\n    ];\n    let dependencies =\n      resolve_dependencies_when_create_database_linked_view(database_layout, &fields).unwrap();\n    assert!(dependencies.deps_fields.is_empty());\n    let layout_setting: LayoutSetting = dependencies\n      .layout_settings\n      .get(&DatabaseLayout::Calendar)\n      .unwrap()\n      .to_owned();\n    let calendar_layout_setting = CalendarLayoutSetting::from(layout_setting);\n    assert_eq!(calendar_layout_setting.field_id, date_field.id);\n  }\n}\n"
  },
  {
    "path": "src/biz/collab/folder_view.rs",
    "content": "use std::collections::HashSet;\n\nuse app_error::AppError;\nuse chrono::DateTime;\nuse collab_folder::{\n  Folder, SectionItem, SpacePermission, View, ViewLayout as CollabFolderViewLayout,\n};\nuse shared_entity::dto::workspace_dto::{\n  self, FavoriteFolderView, FolderView, FolderViewMinimal, RecentFolderView, TrashFolderView,\n  ViewLayout,\n};\nuse uuid::Uuid;\n\nuse crate::biz::collab::utils::DUMMY_UID;\n\npub struct PrivateSpaceAndTrashViews {\n  pub my_private_space_ids: HashSet<Uuid>,\n  pub other_private_space_ids: HashSet<Uuid>,\n  pub view_ids_in_trash: HashSet<Uuid>,\n}\n\npub fn private_space_and_trash_view_ids(\n  uid: i64,\n  folder: &Folder,\n) -> Result<PrivateSpaceAndTrashViews, AppError> {\n  let mut view_ids_in_trash = HashSet::new();\n  let mut my_private_space_ids = HashSet::new();\n  let mut other_private_space_ids = HashSet::new();\n  for private_section in folder.get_my_private_sections(uid) {\n    match folder.get_view(&private_section.id, uid) {\n      Some(private_view) if check_if_view_is_space(&private_view) => {\n        let section_id = Uuid::parse_str(&private_section.id)?;\n        my_private_space_ids.insert(section_id);\n      },\n      _ => (),\n    }\n  }\n\n  for private_section in folder.get_all_private_sections(uid) {\n    let private_section_id = Uuid::parse_str(&private_section.id)?;\n    match folder.get_view(&private_section.id, uid) {\n      Some(private_view)\n        if check_if_view_is_space(&private_view)\n          && !my_private_space_ids.contains(&private_section_id) =>\n      {\n        other_private_space_ids.insert(private_section_id);\n      },\n      _ => (),\n    }\n  }\n  for trash_view in folder.get_all_trash_sections(uid) {\n    let trash_view_id = Uuid::parse_str(&trash_view.id)?;\n    view_ids_in_trash.insert(trash_view_id);\n  }\n  Ok(PrivateSpaceAndTrashViews {\n    my_private_space_ids,\n    other_private_space_ids,\n    view_ids_in_trash,\n  })\n}\n\n/// Return all folders belonging to a workspace, excluding private sections which the user does not have access to.\npub fn collab_folder_to_folder_view(\n  workspace_id: Uuid,\n  root_view_id: &Uuid,\n  folder: &Folder,\n  max_depth: u32,\n  pubished_view_ids: &HashSet<Uuid>,\n  uid: i64,\n) -> Result<FolderView, AppError> {\n  let private_space_and_trash_view_ids = private_space_and_trash_view_ids(uid, folder)?;\n\n  to_folder_view(\n    workspace_id,\n    None,\n    root_view_id,\n    folder,\n    &private_space_and_trash_view_ids,\n    pubished_view_ids,\n    false,\n    0,\n    max_depth,\n    uid,\n  )\n  .ok_or(AppError::InvalidFolderView(format!(\n    \"There is no valid folder view belonging to the root view id: {}\",\n    root_view_id\n  )))\n}\n\npub fn get_prev_view_id(folder: &Folder, view_id: &Uuid, uid: i64) -> Option<Uuid> {\n  let view_id = view_id.to_string();\n  folder\n    .get_view(&view_id.to_string(), uid)\n    .and_then(|view| folder.get_view(&view.parent_view_id, uid))\n    .and_then(|parent_view| {\n      parent_view\n        .children\n        .iter()\n        .position(|vid| vid.id == view_id)\n        .and_then(|pos| {\n          if pos == 0 {\n            None\n          } else {\n            parent_view.children[pos - 1].id.parse().ok()\n          }\n        })\n    })\n}\n\n#[allow(clippy::too_many_arguments)]\nfn to_folder_view(\n  workspace_id: Uuid,\n  parent_view_id: Option<&Uuid>,\n  view_id: &Uuid,\n  folder: &Folder,\n  private_space_and_trash_views: &PrivateSpaceAndTrashViews,\n  published_view_ids: &HashSet<Uuid>,\n  parent_is_private: bool,\n  depth: u32,\n  max_depth: u32,\n  uid: i64,\n) -> Option<FolderView> {\n  let is_trash = private_space_and_trash_views\n    .view_ids_in_trash\n    .contains(view_id);\n  let is_my_private_space = private_space_and_trash_views\n    .my_private_space_ids\n    .contains(view_id);\n  let is_other_private_space = private_space_and_trash_views\n    .other_private_space_ids\n    .contains(view_id);\n\n  if depth > max_depth || is_other_private_space || is_trash {\n    return None;\n  }\n\n  let view = match folder.get_view(&view_id.to_string(), uid) {\n    Some(view) => view,\n    None => {\n      return None;\n    },\n  };\n\n  // There is currently a bug, in which the parent_view_id is not always set correctly\n  let view_parent = Uuid::parse_str(&view.parent_view_id).ok();\n  if parent_view_id.is_some() && view_parent.as_ref() != parent_view_id {\n    return None;\n  }\n\n  let view_is_space = check_if_view_is_space(&view);\n  // There is currently a bug, which a document that is not a space ended up as child\n  // of the workspace\n  let parent_is_workspace = Some(&workspace_id) == parent_view_id;\n  if !view_is_space && parent_is_workspace {\n    return None;\n  }\n\n  let is_private = parent_is_private || is_my_private_space;\n  let extra = view.extra.as_deref().map(|extra| {\n    if extra.is_empty() {\n      serde_json::Value::Null\n    } else {\n      serde_json::from_str::<serde_json::Value>(extra).unwrap_or_else(|e| {\n        tracing::warn!(\"failed to parse extra field({}): {}\", extra, e);\n        serde_json::Value::Null\n      })\n    }\n  });\n  let children: Vec<FolderView> = view\n    .children\n    .iter()\n    .filter_map(|child_view_id| {\n      let child_view_id = Uuid::parse_str(&child_view_id.id).ok()?;\n      to_folder_view(\n        workspace_id,\n        Some(view_id),\n        &child_view_id,\n        folder,\n        private_space_and_trash_views,\n        published_view_ids,\n        is_private,\n        depth + 1,\n        max_depth,\n        uid,\n      )\n    })\n    .collect();\n  Some(FolderView {\n    view_id: *view_id,\n    parent_view_id: view.parent_view_id.parse().ok(),\n    prev_view_id: get_prev_view_id(folder, view_id, uid),\n    name: view.name.clone(),\n    icon: view\n      .icon\n      .as_ref()\n      .map(|icon| to_dto_view_icon(icon.clone())),\n    is_space: view_is_space,\n    is_private,\n    is_favorite: view.is_favorite,\n    is_published: published_view_ids.contains(view_id),\n    layout: to_dto_view_layout(&view.layout),\n    created_at: DateTime::from_timestamp(view.created_at, 0).unwrap_or_default(),\n    created_by: view.created_by,\n    last_edited_by: view.last_edited_by,\n    last_edited_time: DateTime::from_timestamp(view.last_edited_time, 0).unwrap_or_default(),\n    is_locked: view.is_locked,\n    extra,\n    children,\n  })\n}\n\npub fn section_items_to_favorite_folder_view(\n  section_items: &[SectionItem],\n  folder: &Folder,\n  published_view_ids: &HashSet<String>,\n  uid: i64,\n) -> Vec<FavoriteFolderView> {\n  section_items\n    .iter()\n    .filter_map(|section_item| {\n      let view = folder.get_view(&section_item.id, uid);\n      view.map(|v| {\n        let extra = v.extra.as_ref().map(|e| parse_extra_field_as_json(e));\n        let is_pinned = match extra.as_ref() {\n          Some(extra) => extra\n            .get(\"is_pinned\")\n            .and_then(|v| v.as_bool())\n            .unwrap_or(false),\n          None => false,\n        };\n        let view_id = v.id.parse().unwrap();\n        let folder_view = FolderView {\n          view_id,\n          parent_view_id: v.parent_view_id.parse().ok(),\n          prev_view_id: get_prev_view_id(folder, &view_id, uid),\n          name: v.name.clone(),\n          icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())),\n          is_space: false,\n          is_private: false,\n          is_favorite: v.is_favorite,\n          is_published: published_view_ids.contains(&v.id),\n          created_at: DateTime::from_timestamp(v.created_at, 0).unwrap_or_default(),\n          created_by: v.created_by,\n          last_edited_by: v.last_edited_by,\n          last_edited_time: DateTime::from_timestamp(v.last_edited_time, 0).unwrap_or_default(),\n          layout: to_dto_view_layout(&v.layout),\n          is_locked: v.is_locked,\n          extra,\n          children: vec![],\n        };\n        FavoriteFolderView {\n          view: folder_view,\n          favorited_at: DateTime::from_timestamp(section_item.timestamp, 0).unwrap_or_default(),\n          is_pinned,\n        }\n      })\n    })\n    .collect()\n}\n\npub fn section_items_to_recent_folder_view(\n  section_items: &[SectionItem],\n  folder: &Folder,\n  published_view_ids: &HashSet<String>,\n  uid: i64,\n) -> Vec<RecentFolderView> {\n  section_items\n    .iter()\n    .filter_map(|section_item| {\n      let view = folder.get_view(&section_item.id, uid);\n      view.map(|v| {\n        let view_id = v.id.parse().unwrap();\n        let folder_view = FolderView {\n          view_id,\n          parent_view_id: v.parent_view_id.parse().ok(),\n          prev_view_id: get_prev_view_id(folder, &view_id, uid),\n          name: v.name.clone(),\n          icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())),\n          is_space: false,\n          is_private: false,\n          is_favorite: v.is_favorite,\n          is_published: published_view_ids.contains(&v.id),\n          created_at: DateTime::from_timestamp(v.created_at, 0).unwrap_or_default(),\n          created_by: v.created_by,\n          last_edited_by: v.last_edited_by,\n          last_edited_time: DateTime::from_timestamp(v.last_edited_time, 0).unwrap_or_default(),\n          layout: to_dto_view_layout(&v.layout),\n          is_locked: v.is_locked,\n          extra: v.extra.as_ref().map(|e| parse_extra_field_as_json(e)),\n          children: vec![],\n        };\n        RecentFolderView {\n          view: folder_view,\n          last_viewed_at: DateTime::from_timestamp(section_item.timestamp, 0).unwrap_or_default(),\n        }\n      })\n    })\n    .collect()\n}\n\npub fn section_items_to_trash_folder_view(\n  section_items: &[SectionItem],\n  folder: &Folder,\n  uid: i64,\n) -> Vec<TrashFolderView> {\n  section_items\n    .iter()\n    .filter_map(|section_item| {\n      let view = folder.get_view(&section_item.id, uid);\n      view.map(|v| {\n        let view_id = v.id.parse().unwrap();\n        let folder_view = FolderView {\n          view_id,\n          parent_view_id: v.parent_view_id.parse().ok(),\n          prev_view_id: get_prev_view_id(folder, &view_id, uid),\n          name: v.name.clone(),\n          icon: v.icon.as_ref().map(|icon| to_dto_view_icon(icon.clone())),\n          is_space: false,\n          is_private: false,\n          is_published: false,\n          is_favorite: v.is_favorite,\n          created_at: DateTime::from_timestamp(v.created_at, 0).unwrap_or_default(),\n          created_by: v.created_by,\n          last_edited_by: v.last_edited_by,\n          last_edited_time: DateTime::from_timestamp(v.last_edited_time, 0).unwrap_or_default(),\n          layout: to_dto_view_layout(&v.layout),\n          is_locked: v.is_locked,\n          extra: v.extra.as_ref().map(|e| parse_extra_field_as_json(e)),\n          children: vec![],\n        };\n        TrashFolderView {\n          view: folder_view,\n          deleted_at: DateTime::from_timestamp(section_item.timestamp, 0).unwrap_or_default(),\n        }\n      })\n    })\n    .collect()\n}\n\npub struct ViewTree {\n  pub view: View,\n  pub children: Vec<ViewTree>,\n}\n\npub fn get_view_and_children(\n  folder: &Folder,\n  view_id: &str,\n  uid: i64,\n) -> Result<Option<ViewTree>, AppError> {\n  let private_space_and_trash_views = private_space_and_trash_view_ids(uid, folder)?;\n  Ok(get_view_and_children_recursive(\n    folder,\n    &private_space_and_trash_views,\n    view_id,\n    uid,\n  ))\n}\n\nfn get_view_and_children_recursive(\n  folder: &Folder,\n  private_space_and_trash_views: &PrivateSpaceAndTrashViews,\n  view_id: &str,\n  uid: i64,\n) -> Option<ViewTree> {\n  let view_uuid = Uuid::parse_str(view_id).ok()?;\n  if private_space_and_trash_views\n    .view_ids_in_trash\n    .contains(&view_uuid)\n  {\n    return None;\n  }\n\n  folder.get_view(view_id, uid).map(|view| ViewTree {\n    view: View::clone(&view),\n    children: view\n      .children\n      .iter()\n      .filter_map(|child_view_id| {\n        get_view_and_children_recursive(folder, private_space_and_trash_views, child_view_id, uid)\n      })\n      .collect(),\n  })\n}\n\npub fn get_self_and_ancestor_views(\n  folder: &Folder,\n  view_id: &str,\n  uid: i64,\n) -> Result<Vec<View>, AppError> {\n  let mut views = Vec::new();\n  let mut current_view_id = view_id.to_string();\n  let mut visited: HashSet<String> = HashSet::new();\n\n  while let Some(view) = folder.get_view(&current_view_id, uid) {\n    views.push(View::clone(&view));\n    visited.insert(view.id.clone());\n    if view.parent_view_id.is_empty() || visited.contains(&view.parent_view_id) {\n      break;\n    }\n    current_view_id = view.parent_view_id.clone();\n  }\n\n  Ok(views)\n}\n\npub fn get_space_view_for_current_view(folder: &Folder, view_id: &str, uid: i64) -> Option<View> {\n  let mut current_view_id = view_id.to_string();\n  let mut visited: HashSet<String> = HashSet::new();\n\n  while let Some(view) = folder.get_view(&current_view_id, uid) {\n    if check_if_view_is_space(&view) {\n      return Some(View::clone(&view));\n    }\n    visited.insert(view.id.clone());\n    if view.parent_view_id.is_empty() || visited.contains(&view.parent_view_id) {\n      break;\n    }\n    current_view_id = view.parent_view_id.clone();\n  }\n  None\n}\n\npub fn check_if_space_is_private(folder: &Folder, view_id: &str) -> bool {\n  folder\n    .get_all_private_sections(DUMMY_UID)\n    .iter()\n    .any(|s| s.id == view_id)\n}\n\npub fn check_if_view_is_private(folder: &Folder, view_id: &str, uid: i64) -> bool {\n  let mut visited: HashSet<String> = HashSet::new();\n  let private_section_ids = folder\n    .get_all_private_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect::<HashSet<_>>();\n\n  let mut current_view_id = view_id.to_string();\n  while let Some(view) = folder.get_view(&current_view_id, uid) {\n    visited.insert(view.id.clone());\n    if view.parent_view_id.is_empty() || visited.contains(&view.parent_view_id) {\n      break;\n    }\n    if private_section_ids.contains(&view.id) {\n      return true;\n    }\n    current_view_id = view.parent_view_id.clone();\n  }\n  false\n}\n\npub fn check_if_view_is_space(view: &collab_folder::View) -> bool {\n  let extra = match view.extra.as_ref() {\n    Some(extra) => extra,\n    None => return false,\n  };\n  let value = match serde_json::from_str::<serde_json::Value>(extra) {\n    Ok(v) => v,\n    Err(e) => {\n      tracing::error!(\"failed to parse extra field({}): {}\", extra, e);\n      return false;\n    },\n  };\n  match value.get(\"is_space\") {\n    Some(is_space_str) => is_space_str.as_bool().unwrap_or(false),\n    None => false,\n  }\n}\n\npub fn parse_extra_field_as_json(extra: &str) -> serde_json::Value {\n  serde_json::from_str::<serde_json::Value>(extra).unwrap_or_else(|e| {\n    tracing::warn!(\"failed to parse extra field({}): {}\", extra, e);\n    serde_json::Value::Null\n  })\n}\n\npub fn to_dto_view_icon(\n  icon: collab_folder::ViewIcon,\n) -> shared_entity::dto::workspace_dto::ViewIcon {\n  shared_entity::dto::workspace_dto::ViewIcon {\n    ty: to_dto_view_icon_type(icon.ty),\n    value: icon.value,\n  }\n}\n\npub fn to_dto_view_icon_type(\n  icon: collab_folder::IconType,\n) -> shared_entity::dto::workspace_dto::IconType {\n  match icon {\n    collab_folder::IconType::Emoji => shared_entity::dto::workspace_dto::IconType::Emoji,\n    collab_folder::IconType::Url => shared_entity::dto::workspace_dto::IconType::Url,\n    collab_folder::IconType::Icon => shared_entity::dto::workspace_dto::IconType::Icon,\n  }\n}\n\npub fn to_dto_view_layout(collab_folder_view_layout: &CollabFolderViewLayout) -> ViewLayout {\n  match collab_folder_view_layout {\n    CollabFolderViewLayout::Document => ViewLayout::Document,\n    CollabFolderViewLayout::Grid => ViewLayout::Grid,\n    CollabFolderViewLayout::Board => ViewLayout::Board,\n    CollabFolderViewLayout::Calendar => ViewLayout::Calendar,\n    CollabFolderViewLayout::Chat => ViewLayout::Chat,\n  }\n}\n\npub fn to_dto_folder_view_miminal(collab_folder_view: &collab_folder::View) -> FolderViewMinimal {\n  FolderViewMinimal {\n    view_id: collab_folder_view.id.clone(),\n    name: collab_folder_view.name.clone(),\n    icon: collab_folder_view.icon.clone().map(to_dto_view_icon),\n    layout: to_dto_view_layout(&collab_folder_view.layout),\n  }\n}\n\npub fn to_folder_view_icon(icon: workspace_dto::ViewIcon) -> collab_folder::ViewIcon {\n  collab_folder::ViewIcon {\n    ty: to_folder_view_icon_type(icon.ty),\n    value: icon.value,\n  }\n}\n\npub fn to_folder_view_icon_type(icon: workspace_dto::IconType) -> collab_folder::IconType {\n  match icon {\n    workspace_dto::IconType::Emoji => collab_folder::IconType::Emoji,\n    workspace_dto::IconType::Url => collab_folder::IconType::Url,\n    workspace_dto::IconType::Icon => collab_folder::IconType::Icon,\n  }\n}\n\npub fn to_folder_view_layout(layout: workspace_dto::ViewLayout) -> collab_folder::ViewLayout {\n  match layout {\n    ViewLayout::Document => collab_folder::ViewLayout::Document,\n    ViewLayout::Grid => collab_folder::ViewLayout::Grid,\n    ViewLayout::Board => collab_folder::ViewLayout::Board,\n    ViewLayout::Calendar => collab_folder::ViewLayout::Calendar,\n    ViewLayout::Chat => collab_folder::ViewLayout::Chat,\n  }\n}\n\npub fn to_space_permission(space_permission: &workspace_dto::SpacePermission) -> SpacePermission {\n  match space_permission {\n    workspace_dto::SpacePermission::PublicToAll => SpacePermission::PublicToAll,\n    workspace_dto::SpacePermission::Private => SpacePermission::Private,\n  }\n}\n"
  },
  {
    "path": "src/biz/collab/mod.rs",
    "content": "pub mod database;\npub mod folder_view;\npub mod ops;\npub mod publish_outline;\npub mod utils;\n"
  },
  {
    "path": "src/biz/collab/ops.rs",
    "content": "use std::collections::HashMap;\n\nuse app_error::AppError;\nuse chrono::DateTime;\nuse chrono::Utc;\nuse collab::preclude::Collab;\nuse collab_database::database::gen_field_id;\nuse collab_database::entity::FieldType;\nuse collab_database::fields::Field;\nuse collab_database::fields::TypeOptions;\nuse collab_database::rows::meta_id_from_row_id;\nuse collab_database::rows::CreateRowParams;\nuse collab_database::rows::DatabaseRowBody;\nuse collab_database::rows::Row;\nuse collab_database::rows::RowDetail;\nuse collab_database::rows::RowId;\nuse collab_database::rows::RowMetaKey;\nuse collab_database::views::OrderObjectPosition;\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_database::workspace_database::WorkspaceDatabaseBody;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_entity::EncodedCollab;\nuse collab_folder::hierarchy_builder::NestedChildViewBuilder;\nuse collab_folder::Folder;\nuse collab_folder::SectionItem;\nuse collab_folder::{CollabOrigin, SpaceInfo};\nuse collab_rt_entity::user::RealtimeUser;\nuse database::collab::select_last_updated_database_row_ids;\nuse database::collab::select_workspace_database_oid;\nuse database::collab::{CollabStore, GetCollabOrigin};\nuse database::publish::select_published_view_ids_for_workspace;\nuse database::publish::select_published_view_ids_with_publish_info_for_workspace;\nuse database::publish::select_workspace_id_for_publish_namespace;\nuse database_entity::dto::CollabParams;\nuse database_entity::dto::QueryCollab;\nuse database_entity::dto::QueryCollabResult;\n\nuse shared_entity::dto::workspace_dto::AFDatabase;\nuse shared_entity::dto::workspace_dto::AFDatabaseField;\nuse shared_entity::dto::workspace_dto::AFDatabaseRow;\nuse shared_entity::dto::workspace_dto::AFDatabaseRowDetail;\nuse shared_entity::dto::workspace_dto::AFInsertDatabaseField;\nuse shared_entity::dto::workspace_dto::DatabaseRowUpdatedItem;\nuse shared_entity::dto::workspace_dto::FavoriteFolderView;\nuse shared_entity::dto::workspace_dto::FolderViewMinimal;\nuse shared_entity::dto::workspace_dto::PublishedViewInfo;\nuse shared_entity::dto::workspace_dto::RecentFolderView;\nuse shared_entity::dto::workspace_dto::TrashFolderView;\nuse sqlx::PgPool;\nuse yrs::Map;\n\nuse super::folder_view::collab_folder_to_folder_view;\nuse super::folder_view::section_items_to_favorite_folder_view;\nuse super::folder_view::section_items_to_recent_folder_view;\nuse super::folder_view::section_items_to_trash_folder_view;\nuse super::folder_view::to_dto_folder_view_miminal;\nuse super::publish_outline::collab_folder_to_published_outline;\nuse super::utils::collab_to_bin;\nuse super::utils::create_row_document;\nuse super::utils::field_by_id_name_uniq;\nuse super::utils::get_latest_collab;\nuse super::utils::get_latest_collab_database_body;\nuse super::utils::get_latest_collab_database_row_body;\nuse super::utils::get_row_details_serde;\nuse super::utils::type_option_reader_by_id;\nuse super::utils::type_options_serde;\nuse super::utils::write_to_database_row;\nuse super::utils::CreatedRowDocument;\nuse super::utils::DocChanges;\nuse super::utils::DEFAULT_SPACE_ICON;\nuse super::utils::DEFAULT_SPACE_ICON_COLOR;\nuse crate::api::metrics::AppFlowyWebMetrics;\nuse crate::biz::collab::folder_view::check_if_view_is_space;\nuse crate::biz::collab::utils::get_database_row_doc_changes;\nuse crate::biz::workspace::page_view::update_workspace_folder_data;\nuse crate::state::AppState;\nuse appflowy_collaborate::ws2::{CollabUpdatePublisher, WorkspaceCollabInstanceCache};\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse shared_entity::dto::workspace_dto::{FolderView, PublishedView};\nuse sqlx::types::Uuid;\nuse std::collections::HashSet;\nuse std::sync::Arc;\nuse yrs::block::ClientID;\n\npub async fn get_user_favorite_folder_views(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  pg_pool: &PgPool,\n  uid: i64,\n  workspace_id: Uuid,\n) -> Result<Vec<FavoriteFolderView>, AppError> {\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let publish_view_ids = select_published_view_ids_for_workspace(pg_pool, workspace_id).await?;\n  let publish_view_ids: HashSet<String> = publish_view_ids\n    .into_iter()\n    .map(|id| id.to_string())\n    .collect();\n  let deleted_section_item_ids: Vec<String> = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n  let favorite_section_items: Vec<SectionItem> = folder\n    .get_my_favorite_sections(uid)\n    .into_iter()\n    .filter(|s| !deleted_section_item_ids.contains(&s.id))\n    .collect();\n  Ok(section_items_to_favorite_folder_view(\n    &favorite_section_items,\n    &folder,\n    &publish_view_ids,\n    uid,\n  ))\n}\n\npub async fn get_user_recent_folder_views(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  pg_pool: &PgPool,\n  uid: i64,\n  workspace_id: Uuid,\n) -> Result<Vec<RecentFolderView>, AppError> {\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let deleted_section_item_ids: Vec<String> = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n  let recent_section_items: Vec<SectionItem> = folder\n    .get_my_recent_sections(uid)\n    .into_iter()\n    .filter(|s| !deleted_section_item_ids.contains(&s.id))\n    .collect();\n  let publish_view_ids = select_published_view_ids_for_workspace(pg_pool, workspace_id).await?;\n  let publish_view_ids: HashSet<String> = publish_view_ids\n    .into_iter()\n    .map(|id| id.to_string())\n    .collect();\n  Ok(section_items_to_recent_folder_view(\n    &recent_section_items,\n    &folder,\n    &publish_view_ids,\n    uid,\n  ))\n}\n\npub async fn get_user_trash_folder_views(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  uid: i64,\n  workspace_id: Uuid,\n) -> Result<Vec<TrashFolderView>, AppError> {\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let section_items = folder.get_my_trash_sections(uid);\n  Ok(section_items_to_trash_folder_view(\n    &section_items,\n    &folder,\n    uid,\n  ))\n}\n\n#[allow(clippy::too_many_arguments)]\nfn patch_old_workspace_folder(\n  user: RealtimeUser,\n  workspace_id: &str,\n  folder: &mut Folder,\n  child_view_id_without_space: &[String],\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let space_id = Uuid::new_v4().to_string();\n\n    let space_view = NestedChildViewBuilder::new(user.uid, workspace_id.to_string())\n      .with_view_id(space_id.clone())\n      .with_name(\"General\")\n      .with_extra(|extra| {\n        extra\n          .with_space_info(SpaceInfo {\n            space_icon: Some(DEFAULT_SPACE_ICON.to_string()),\n            space_icon_color: Some(DEFAULT_SPACE_ICON_COLOR.to_string()),\n            ..Default::default()\n          })\n          .build()\n      })\n      .build()\n      .view;\n    let mut txn = folder.collab.transact_mut();\n    folder\n      .body\n      .views\n      .insert(&mut txn, space_view, None, user.uid);\n    for (i, current_view_id) in child_view_id_without_space.iter().enumerate() {\n      let previous_view_id = if i == 0 {\n        None\n      } else {\n        Some(child_view_id_without_space[i - 1].clone())\n      };\n      folder.body.move_nested_view(\n        &mut txn,\n        current_view_id,\n        &space_id,\n        previous_view_id,\n        user.uid,\n      );\n    }\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn fix_old_workspace_folder(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  mut folder: Folder,\n  workspace_id: Uuid,\n) -> Result<Folder, AppError> {\n  let root_view = folder\n    .get_view(&workspace_id.to_string(), user.uid)\n    .ok_or_else(|| {\n      AppError::InvalidRequest(format!(\n        \"Failed to get view for workspace_id: {}\",\n        workspace_id\n      ))\n    })?;\n  let direct_workspace_children: Vec<String> = root_view\n    .children\n    .iter()\n    .map(|view_id| view_id.to_string())\n    .collect();\n  let has_at_least_one_space = direct_workspace_children\n    .iter()\n    .filter_map(|view_id| folder.get_view(view_id, user.uid))\n    .any(|view| check_if_view_is_space(&view));\n  if !has_at_least_one_space {\n    let folder_update = patch_old_workspace_folder(\n      user.clone(),\n      &workspace_id.to_string(),\n      &mut folder,\n      &direct_workspace_children,\n    )?;\n    update_workspace_folder_data(\n      appflowy_web_metrics,\n      update_publisher,\n      user,\n      workspace_id,\n      folder_update,\n    )\n    .await?;\n  }\n  Ok(folder)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn get_user_workspace_structure(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  depth: u32,\n  root_view_id: &Uuid,\n) -> Result<FolderView, AppError> {\n  let appflowy_web_metrics = &state.metrics.appflowy_web_metrics;\n  let depth_limit = 10;\n  if depth > depth_limit {\n    return Err(AppError::InvalidRequest(format!(\n      \"Depth {} is too large (limit: {})\",\n      depth, depth_limit\n    )));\n  }\n  let folder = state.ws_server.get_folder(workspace_id).await?;\n  let patched_folder = fix_old_workspace_folder(\n    appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    folder,\n    workspace_id,\n  )\n  .await?;\n\n  let publish_view_ids =\n    select_published_view_ids_for_workspace(&state.pg_pool, workspace_id).await?;\n  let publish_view_ids: HashSet<_> = publish_view_ids.into_iter().collect();\n  collab_folder_to_folder_view(\n    workspace_id,\n    root_view_id,\n    &patched_folder,\n    depth,\n    &publish_view_ids,\n    user.uid,\n  )\n}\n\npub async fn get_latest_workspace_database(\n  collab_storage: &Arc<dyn CollabStore>,\n  pg_pool: &PgPool,\n  collab_origin: GetCollabOrigin,\n  workspace_id: Uuid,\n) -> Result<(Uuid, WorkspaceDatabase), AppError> {\n  let workspace_database_oid = select_workspace_database_oid(pg_pool, &workspace_id).await?;\n  let workspace_database_collab = get_latest_collab(\n    collab_storage,\n    collab_origin,\n    workspace_id,\n    workspace_database_oid,\n    CollabType::WorkspaceDatabase,\n    default_client_id(),\n  )\n  .await?;\n\n  let workspace_database = WorkspaceDatabase::open(workspace_database_collab)\n    .map_err(|err| AppError::Unhandled(format!(\"failed to open workspace database: {}\", err)))?;\n  Ok((workspace_database_oid, workspace_database))\n}\n\npub async fn get_published_view(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  publish_namespace: String,\n  pg_pool: &PgPool,\n  uid: i64,\n) -> Result<PublishedView, AppError> {\n  let workspace_id = select_workspace_id_for_publish_namespace(pg_pool, &publish_namespace).await?;\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let publish_view_ids_with_publish_info =\n    select_published_view_ids_with_publish_info_for_workspace(pg_pool, workspace_id).await?;\n  let publish_view_id_to_info_map: HashMap<String, PublishedViewInfo> =\n    publish_view_ids_with_publish_info\n      .into_iter()\n      .map(|pv| {\n        (\n          pv.view_id.to_string(),\n          PublishedViewInfo {\n            publisher_email: pv.publisher_email.clone(),\n            publish_name: pv.publish_name.clone(),\n            publish_timestamp: pv.publish_timestamp,\n            comments_enabled: pv.comments_enabled,\n            duplicate_enabled: pv.duplicate_enabled,\n          },\n        )\n      })\n      .collect();\n\n  let published_view: PublishedView = collab_folder_to_published_outline(\n    &workspace_id.to_string(),\n    &folder,\n    &publish_view_id_to_info_map,\n    uid,\n  )?;\n  Ok(published_view)\n}\n\npub async fn list_database(\n  pg_pool: &PgPool,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_id: Uuid,\n) -> Result<Vec<AFDatabase>, AppError> {\n  let ws_db_oid = select_workspace_database_oid(pg_pool, &workspace_id).await?;\n\n  let mut ws_body_collab = get_latest_collab(\n    collab_storage,\n    GetCollabOrigin::Server,\n    workspace_id,\n    ws_db_oid,\n    CollabType::WorkspaceDatabase,\n    default_client_id(),\n  )\n  .await?;\n\n  let ws_body = WorkspaceDatabaseBody::open(&mut ws_body_collab).map_err(|e| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to open workspace database body: {:?}\",\n      e\n    ))\n  })?;\n  let db_metas = ws_body.get_all_meta(&ws_body_collab.transact());\n\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let trash = folder\n    .get_all_trash_sections(uid)\n    .into_iter()\n    .map(|s| s.id)\n    .collect::<HashSet<_>>();\n\n  let mut af_databases = Vec::with_capacity(db_metas.len());\n  for db_meta in db_metas {\n    let id = db_meta.database_id;\n    let mut views: Vec<FolderViewMinimal> = Vec::new();\n    for linked_view_id in db_meta.linked_views {\n      if !trash.contains(&linked_view_id) {\n        if let Some(folder_view) = folder.get_view(&linked_view_id, uid) {\n          views.push(to_dto_folder_view_miminal(&folder_view));\n        };\n      }\n    }\n    if !views.is_empty() {\n      af_databases.push(AFDatabase { id, views });\n    }\n  }\n\n  Ok(af_databases)\n}\n\npub async fn list_database_row_ids(\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n) -> Result<Vec<AFDatabaseRow>, AppError> {\n  let (db_collab, db_body) =\n    get_latest_collab_database_body(collab_storage, workspace_uuid, database_uuid).await?;\n  // get any view_id\n  let txn = db_collab.transact();\n  let iid = db_body.get_inline_view_id(&txn);\n\n  let iview = db_body.views.get_view(&txn, &iid).ok_or_else(|| {\n    AppError::Internal(anyhow::anyhow!(\"Failed to get inline view, iid: {}\", iid))\n  })?;\n\n  let db_rows = iview\n    .row_orders\n    .into_iter()\n    .map(|row_order| AFDatabaseRow {\n      id: row_order.id.to_string(),\n    })\n    .collect();\n\n  Ok(db_rows)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn insert_database_row(\n  state: &AppState,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n  uid: i64,\n  new_db_row_id: Option<Uuid>,\n  cell_value_by_id: HashMap<String, serde_json::Value>,\n  row_doc_content: Option<String>,\n) -> Result<String, AppError> {\n  let new_db_row_id = new_db_row_id.unwrap_or_else(Uuid::new_v4);\n  let new_db_row_id_str = RowId::from(new_db_row_id.to_string());\n  let creation_time = Utc::now();\n  let client_id = default_client_id();\n\n  let options = CollabOptions::new(new_db_row_id.to_string(), client_id);\n  let mut new_db_row_collab = Collab::new_with_options(CollabOrigin::Empty, options)\n    .map_err(|err| AppError::Internal(err.into()))?;\n  let new_db_row_body = DatabaseRowBody::create(\n    new_db_row_id_str.clone(),\n    &mut new_db_row_collab,\n    Row::empty(new_db_row_id_str, &database_uuid.to_string()),\n  );\n  new_db_row_body.update(&mut new_db_row_collab.transact_mut(), |row_update| {\n    row_update.set_created_at(Utc::now().timestamp());\n  });\n\n  let new_row_doc_creation: Option<(Uuid, CreatedRowDocument)> = match row_doc_content {\n    Some(row_doc_content) if !row_doc_content.is_empty() => {\n      // update row to indicate that the document is not empty\n      let is_document_empty_id = meta_id_from_row_id(&new_db_row_id, RowMetaKey::IsDocumentEmpty);\n      new_db_row_body.get_meta().insert(\n        &mut new_db_row_collab.transact_mut(),\n        is_document_empty_id,\n        false,\n      );\n\n      // get document id\n      let new_doc_id = new_db_row_body\n        .document_id(&new_db_row_collab.transact())\n        .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to get document id: {:?}\", err)))?\n        .ok_or_else(|| AppError::Internal(anyhow::anyhow!(\"Failed to get document id\")))?;\n\n      let new_doc_id = Uuid::parse_str(&new_doc_id)?;\n      let created_row_doc = create_row_document(\n        workspace_uuid,\n        uid,\n        new_doc_id,\n        &state.ws_server,\n        row_doc_content,\n      )\n      .await?;\n      Some((new_doc_id, created_row_doc))\n    },\n    _ => None,\n  };\n\n  let (mut db_collab, db_body) =\n    get_latest_collab_database_body(&state.collab_storage, workspace_uuid, database_uuid).await?;\n  write_to_database_row(\n    &db_body,\n    &mut new_db_row_collab.transact_mut(),\n    &new_db_row_body,\n    cell_value_by_id,\n    creation_time.timestamp(),\n  )\n  .await?;\n\n  // Create new row order\n  let ts_now = creation_time.timestamp();\n  let row_order = db_body\n    .create_row(\n      CreateRowParams {\n        id: new_db_row_id.to_string().into(),\n        database_id: database_uuid.to_string(),\n        cells: new_db_row_body\n          .cells(&new_db_row_collab.transact())\n          .unwrap_or_default(),\n        height: 30,\n        visibility: true,\n        row_position: OrderObjectPosition::End,\n        created_at: ts_now,\n        modified_at: ts_now,\n      },\n      client_id,\n    )\n    .await\n    .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to create row: {:?}\", e)))?;\n\n  let new_db_row_ec_v1 = collab_to_bin(new_db_row_collab, CollabType::DatabaseRow).await?;\n\n  // For each database view, add the new row order\n  let db_collab_update = {\n    let mut txn = db_collab.transact_mut();\n    let mut db_views = db_body.views.get_all_views(&txn);\n    for db_view in db_views.iter_mut() {\n      db_view.row_orders.push(row_order.clone());\n    }\n    db_body.views.clear(&mut txn);\n    for view in db_views {\n      db_body.views.insert_view(&mut txn, view);\n    }\n    txn.encode_update_v1()\n  };\n\n  state\n    .ws_server\n    .publish_update(\n      workspace_uuid,\n      database_uuid,\n      CollabType::Database,\n      &CollabOrigin::Server,\n      db_collab_update.clone(),\n    )\n    .await?;\n\n  let collab_storage = state.collab_storage.clone();\n  let mut db_txn = state.pg_pool.begin().await?;\n  // handle row document (if provided)\n  if let Some((doc_id, created_doc)) = new_row_doc_creation {\n    state\n      .ws_server\n      .publish_update(\n        workspace_uuid,\n        workspace_uuid,\n        CollabType::Folder,\n        &CollabOrigin::Server,\n        created_doc.folder_updates,\n      )\n      .await?;\n\n    // insert document\n    collab_storage\n      .upsert_new_collab_with_transaction(\n        workspace_uuid,\n        &uid,\n        CollabParams {\n          object_id: doc_id,\n          encoded_collab_v1: created_doc.doc_ec_bytes.into(),\n          collab_type: CollabType::Document,\n          updated_at: None,\n        },\n        &mut db_txn,\n        \"inserting new database row document from server\",\n      )\n      .await?;\n  };\n\n  // insert row\n  collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_uuid,\n      &uid,\n      CollabParams {\n        object_id: new_db_row_id,\n        encoded_collab_v1: new_db_row_ec_v1.into(),\n        collab_type: CollabType::DatabaseRow,\n        updated_at: None,\n      },\n      &mut db_txn,\n      \"inserting new database row from server\",\n    )\n    .await?;\n\n  db_txn.commit().await?;\n  Ok(new_db_row_id.to_string())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn upsert_database_row(\n  state: &AppState,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n  uid: i64,\n  row_id: Uuid,\n  cell_value_by_id: HashMap<String, serde_json::Value>,\n  row_doc_content: Option<String>,\n) -> Result<(), AppError> {\n  let collab_storage = &state.collab_storage;\n  let (mut db_row_collab, db_row_body) =\n    match get_latest_collab_database_row_body(collab_storage, workspace_uuid, row_id).await {\n      Ok(res) => res,\n      Err(err) => match err {\n        AppError::RecordNotFound(_) => {\n          return insert_database_row(\n            state,\n            workspace_uuid,\n            database_uuid,\n            uid,\n            Some(row_id),\n            cell_value_by_id,\n            row_doc_content,\n          )\n          .await\n          .map(|_id| {});\n        },\n        _ => return Err(err),\n      },\n    };\n\n  // At this point, db row exists,\n  // so we modify it, put into storage and broadcast change\n  let (_db_collab, db_body) =\n    get_latest_collab_database_body(collab_storage, workspace_uuid, database_uuid).await?;\n\n  let mut db_row_txn = db_row_collab.transact_mut();\n  write_to_database_row(\n    &db_body,\n    &mut db_row_txn,\n    &db_row_body,\n    cell_value_by_id,\n    Utc::now().timestamp(),\n  )\n  .await?;\n\n  // determine if there are any document changes\n  let doc_changes: Option<(Uuid, DocChanges)> = get_database_row_doc_changes(\n    collab_storage,\n    &state.ws_server,\n    workspace_uuid,\n    row_doc_content,\n    &db_row_body,\n    &mut db_row_txn,\n    &row_id,\n    uid,\n  )\n  .await?;\n\n  // finalize update for database row\n  let db_row_collab_updates = db_row_txn.encode_update_v1();\n  drop(db_row_txn);\n  state\n    .ws_server\n    .publish_update(\n      workspace_uuid,\n      row_id,\n      CollabType::DatabaseRow,\n      &CollabOrigin::Server,\n      db_row_collab_updates,\n    )\n    .await?;\n\n  let db_row_ec_v1 = collab_to_bin(db_row_collab, CollabType::DatabaseRow).await?;\n  // write to disk and broadcast changes\n  let mut db_txn = state.pg_pool.begin().await?;\n  collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_uuid,\n      &uid,\n      CollabParams {\n        object_id: row_id,\n        encoded_collab_v1: db_row_ec_v1.into(),\n        collab_type: CollabType::DatabaseRow,\n        updated_at: None,\n      },\n      &mut db_txn,\n      \"inserting new database row from server\",\n    )\n    .await?;\n\n  // handle document changes\n  if let Some((doc_id, doc_changes)) = doc_changes {\n    match doc_changes {\n      DocChanges::Update(updated_doc, doc_update) => {\n        collab_storage\n          .upsert_new_collab_with_transaction(\n            workspace_uuid,\n            &uid,\n            CollabParams {\n              object_id: doc_id,\n              encoded_collab_v1: updated_doc.into(),\n              collab_type: CollabType::Document,\n              updated_at: None,\n            },\n            &mut db_txn,\n            \"updating database row document from server\",\n          )\n          .await?;\n\n        state\n          .ws_server\n          .publish_update(\n            workspace_uuid,\n            doc_id,\n            CollabType::Document,\n            &CollabOrigin::Server,\n            doc_update,\n          )\n          .await?;\n      },\n      DocChanges::Insert(created_doc) => {\n        let CreatedRowDocument {\n          folder_updates,\n          doc_ec_bytes,\n        } = created_doc;\n\n        state\n          .ws_server\n          .publish_update(\n            workspace_uuid,\n            workspace_uuid,\n            CollabType::Folder,\n            &CollabOrigin::Server,\n            folder_updates,\n          )\n          .await?;\n\n        // insert document\n        collab_storage\n          .upsert_new_collab_with_transaction(\n            workspace_uuid,\n            &uid,\n            CollabParams {\n              object_id: doc_id,\n              encoded_collab_v1: doc_ec_bytes.into(),\n              collab_type: CollabType::Document,\n              updated_at: None,\n            },\n            &mut db_txn,\n            \"inserting new database row document from server\",\n          )\n          .await?;\n      },\n    }\n  }\n\n  db_txn.commit().await?;\n  Ok(())\n}\n\npub async fn get_database_fields(\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n) -> Result<Vec<AFDatabaseField>, AppError> {\n  let (db_collab, db_body) =\n    get_latest_collab_database_body(collab_storage, workspace_uuid, database_uuid).await?;\n\n  let all_fields = db_body.fields.get_all_fields(&db_collab.transact());\n  let mut acc = Vec::with_capacity(all_fields.len());\n  for field in all_fields {\n    let field_type = FieldType::from(field.field_type);\n    acc.push(AFDatabaseField {\n      id: field.id,\n      name: field.name,\n      field_type: format!(\"{:?}\", field_type),\n      type_option: type_options_serde(&field.type_options, &field_type),\n      is_primary: field.is_primary,\n    });\n  }\n  Ok(acc)\n}\n\n// inserts a new field into the database\n// returns the id of the field created\npub async fn add_database_field(\n  state: &AppState,\n  workspace_id: Uuid,\n  database_id: Uuid,\n  insert_field: AFInsertDatabaseField,\n) -> Result<String, AppError> {\n  let (mut db_collab, db_body) =\n    get_latest_collab_database_body(&state.collab_storage, workspace_id, database_id).await?;\n\n  let new_id = gen_field_id();\n  let mut type_options = TypeOptions::new();\n  let type_option_data = insert_field\n    .type_option_data\n    .unwrap_or(serde_json::json!({}));\n\n  match serde_json::from_value(type_option_data) {\n    Ok(tod) => type_options.insert(insert_field.field_type.to_string(), tod),\n    Err(err) => {\n      return Err(AppError::InvalidRequest(format!(\n        \"Failed to parse type option: {:?}\",\n        err\n      )));\n    },\n  };\n\n  let new_field = Field {\n    id: new_id.clone(),\n    name: insert_field.name,\n    field_type: insert_field.field_type,\n    type_options,\n    ..Default::default()\n  };\n\n  let db_collab_update = {\n    let mut yrs_txn = db_collab.transact_mut();\n    db_body.create_field(\n      &mut yrs_txn,\n      None,\n      new_field,\n      &OrderObjectPosition::End,\n      &HashMap::new(),\n    );\n    yrs_txn.encode_update_v1()\n  };\n\n  state\n    .ws_server\n    .publish_update(\n      workspace_id,\n      database_id,\n      CollabType::Database,\n      &CollabOrigin::Server,\n      db_collab_update,\n    )\n    .await?;\n\n  Ok(new_id)\n}\n\npub async fn list_database_row_ids_updated(\n  collab_storage: &Arc<dyn CollabStore>,\n  pg_pool: &PgPool,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n  after: &DateTime<Utc>,\n) -> Result<Vec<DatabaseRowUpdatedItem>, AppError> {\n  let row_ids: Vec<_> = list_database_row_ids(collab_storage, workspace_uuid, database_uuid)\n    .await?\n    .into_iter()\n    .flat_map(|row| Uuid::parse_str(&row.id))\n    .collect();\n\n  let updated_row_ids =\n    select_last_updated_database_row_ids(pg_pool, &workspace_uuid, &row_ids, after).await?;\n  Ok(updated_row_ids)\n}\n\npub async fn list_database_row_details(\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_uuid: Uuid,\n  database_uuid: Uuid,\n  row_ids: &[Uuid],\n  unsupported_field_types: &[FieldType],\n  with_doc: bool,\n) -> Result<Vec<AFDatabaseRowDetail>, AppError> {\n  let (database_collab, db_body) =\n    get_latest_collab_database_body(collab_storage, workspace_uuid, database_uuid).await?;\n\n  let all_fields: Vec<Field> = db_body\n    .fields\n    .get_all_fields(&database_collab.transact())\n    .into_iter()\n    .filter(|field| !unsupported_field_types.contains(&FieldType::from(field.field_type)))\n    .collect();\n  if all_fields.is_empty() {\n    return Ok(vec![]);\n  }\n\n  let type_option_reader_by_id = type_option_reader_by_id(&all_fields);\n  let field_by_id = field_by_id_name_uniq(all_fields);\n  let client_id = default_client_id();\n  let query_collabs: Vec<QueryCollab> = row_ids\n    .iter()\n    .map(|id| QueryCollab {\n      object_id: *id,\n      collab_type: CollabType::DatabaseRow,\n    })\n    .collect();\n  let mut db_row_details = collab_storage\n    .batch_get_collab(&uid, workspace_uuid, query_collabs)\n    .await\n    .into_iter()\n    .flat_map(|(id, result)| match result {\n      QueryCollabResult::Success { encode_collab_v1 } => {\n        let ec = match EncodedCollab::decode_from_bytes(&encode_collab_v1) {\n          Ok(ec) => ec,\n          Err(err) => {\n            tracing::error!(\"Failed to decode encoded collab: {:?}\", err);\n            return None;\n          },\n        };\n        let id = id.to_string();\n        let options = collab::core::collab::CollabOptions::new(id.to_string(), client_id)\n          .with_data_source(ec.into());\n        let collab = match Collab::new_with_options(CollabOrigin::Server, options) {\n          Ok(collab) => collab,\n          Err(err) => {\n            tracing::error!(\"Failed to create collab: {:?}\", err);\n            return None;\n          },\n        };\n        let row_detail = match RowDetail::from_collab(&collab) {\n          Some(row_detail) => row_detail,\n          None => {\n            tracing::error!(\"Failed to get row detail from collab: {:?}\", collab);\n            return None;\n          },\n        };\n\n        let has_doc = !row_detail.meta.is_document_empty;\n        let cells = get_row_details_serde(row_detail, &field_by_id, &type_option_reader_by_id);\n        Some(AFDatabaseRowDetail {\n          id,\n          cells,\n          has_doc,\n          doc: None,\n        })\n      },\n      QueryCollabResult::Failed { error } => {\n        tracing::warn!(\"Failed to get collab: {:?}\", error);\n        None\n      },\n    })\n    .collect::<Vec<AFDatabaseRowDetail>>();\n\n  // Fill in the document content if requested and exists\n  if with_doc {\n    let doc_id_by_row_id = db_row_details\n      .iter()\n      .filter(|row| row.has_doc)\n      .flat_map(|row| {\n        row.id.parse::<Uuid>().ok().map(|row_uuid| {\n          (\n            row_uuid,\n            meta_id_from_row_id(&row_uuid, RowMetaKey::DocumentId)\n              .parse::<Uuid>()\n              .unwrap(),\n          )\n        })\n      })\n      .collect::<HashMap<_, _>>();\n    let query_db_docs = doc_id_by_row_id\n      .values()\n      .map(|doc_id| QueryCollab {\n        object_id: *doc_id,\n        collab_type: CollabType::Document,\n      })\n      .collect::<Vec<_>>();\n    let mut query_res = collab_storage\n      .batch_get_collab(&uid, workspace_uuid, query_db_docs)\n      .await;\n    for row_detail in &mut db_row_details {\n      if let Err(err) = fill_in_db_row_doc(client_id, row_detail, &doc_id_by_row_id, &mut query_res)\n      {\n        tracing::error!(\"Failed to fill in document content: {:?}\", err);\n      };\n    }\n  }\n\n  Ok(db_row_details)\n}\n\nfn fill_in_db_row_doc(\n  client_id: ClientID,\n  row_detail: &mut AFDatabaseRowDetail,\n  doc_id_by_row_id: &HashMap<Uuid, Uuid>,\n  query_res: &mut HashMap<Uuid, QueryCollabResult>,\n) -> Result<(), AppError> {\n  let doc_id = doc_id_by_row_id\n    .get(&row_detail.id.parse()?)\n    .ok_or_else(|| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to get document id for row id: {}\",\n        row_detail.id\n      ))\n    })?;\n  let res = query_res.remove(doc_id).ok_or_else(|| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to get document collab for row id: {}\",\n      row_detail.id\n    ))\n  })?;\n\n  let ec_bytes = match res {\n    QueryCollabResult::Success { encode_collab_v1 } => encode_collab_v1,\n    QueryCollabResult::Failed { error } => return Err(AppError::Internal(anyhow::anyhow!(error))),\n  };\n  let ec = EncodedCollab::decode_from_bytes(&ec_bytes)?;\n  let options = collab::core::collab::CollabOptions::new(doc_id.to_string(), client_id)\n    .with_data_source(ec.into());\n  let doc_collab = Collab::new_with_options(CollabOrigin::Server, options).map_err(|err| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to create document collab: {:?}\",\n      err\n    ))\n  })?;\n  let doc = Document::open(doc_collab)\n    .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to open document: {:?}\", err)))?;\n  let plain_text = doc.paragraphs().join(\"\");\n  row_detail.doc = Some(plain_text);\n  Ok(())\n}\n"
  },
  {
    "path": "src/biz/collab/publish_outline.rs",
    "content": "use std::collections::{HashMap, HashSet};\n\nuse app_error::AppError;\nuse collab_folder::Folder;\nuse shared_entity::dto::workspace_dto::{PublishedView, PublishedViewInfo};\n\nuse super::folder_view::{to_dto_view_icon, to_dto_view_layout};\n\n/// Returns only folders that are published, or one of the nested subfolders is published.\n/// Exclude folders that are in the trash.\npub fn collab_folder_to_published_outline(\n  root_view_id: &str,\n  folder: &Folder,\n  publish_view_id_to_info_map: &HashMap<String, PublishedViewInfo>,\n  uid: i64,\n) -> Result<PublishedView, AppError> {\n  let mut unviewable = HashSet::new();\n  for trash_view in folder.get_all_trash_sections(uid) {\n    unviewable.insert(trash_view.id);\n  }\n\n  let max_depth = 10;\n  to_publish_view(\n    \"\",\n    root_view_id,\n    folder,\n    &unviewable,\n    publish_view_id_to_info_map,\n    0,\n    max_depth,\n    uid,\n  )\n  .ok_or(AppError::InvalidPublishedOutline(format!(\n    \"failed to get published outline for root view id: {}\",\n    root_view_id\n  )))\n}\n\n#[allow(clippy::too_many_arguments)]\nfn to_publish_view(\n  parent_view_id: &str,\n  view_id: &str,\n  folder: &Folder,\n  unviewable: &HashSet<String>,\n  publish_view_id_to_info_map: &HashMap<String, PublishedViewInfo>,\n  depth: u32,\n  max_depth: u32,\n  uid: i64,\n) -> Option<PublishedView> {\n  if depth > max_depth || unviewable.contains(view_id) {\n    return None;\n  }\n\n  let view = match folder.get_view(view_id, uid) {\n    Some(view) => view,\n    None => {\n      return None;\n    },\n  };\n\n  // There is currently a bug, in which the parent_view_id is not always set correctly\n  if !(parent_view_id.is_empty() || view.parent_view_id == parent_view_id) {\n    return None;\n  }\n\n  let extra = view.extra.as_deref().map(|extra| {\n    serde_json::from_str::<serde_json::Value>(extra).unwrap_or_else(|e| {\n      tracing::warn!(\"failed to parse extra field({}): {}\", extra, e);\n      serde_json::Value::Null\n    })\n  });\n  // If pruned_view is not empty, then one or more of the children is published.\n  // Hence, this view should be included in the published outline, even if it is not published itself.\n  let pruned_view: Vec<PublishedView> = view\n    .children\n    .iter()\n    .filter_map(|child_view_id| {\n      to_publish_view(\n        view_id,\n        &child_view_id.id,\n        folder,\n        unviewable,\n        publish_view_id_to_info_map,\n        depth + 1,\n        max_depth,\n        uid,\n      )\n    })\n    .collect();\n  let is_published = publish_view_id_to_info_map.contains_key(view_id);\n  if parent_view_id.is_empty() || is_published || !pruned_view.is_empty() {\n    Some(PublishedView {\n      view_id: view.id.clone(),\n      name: view.name.clone(),\n      icon: view\n        .icon\n        .as_ref()\n        .map(|icon| to_dto_view_icon(icon.clone())),\n      is_published,\n      layout: to_dto_view_layout(&view.layout),\n      extra,\n      children: pruned_view,\n      info: publish_view_id_to_info_map.get(view_id).cloned(),\n    })\n  } else {\n    None\n  }\n}\n"
  },
  {
    "path": "src/biz/collab/utils.rs",
    "content": "use app_error::AppError;\nuse appflowy_collaborate::ws2::WorkspaceCollabInstanceCache;\nuse collab::core::collab::{default_client_id, CollabOptions, DataSource};\nuse collab::preclude::Collab;\nuse collab_database::database::DatabaseBody;\nuse collab_database::database_trait::NoPersistenceDatabaseCollabService;\nuse collab_database::entity::FieldType;\nuse collab_database::fields::type_option_cell_reader;\nuse collab_database::fields::type_option_cell_writer;\nuse collab_database::fields::Field;\nuse collab_database::fields::TypeOptionCellReader;\nuse collab_database::fields::TypeOptionCellWriter;\nuse collab_database::fields::TypeOptionData;\nuse collab_database::fields::TypeOptions;\nuse collab_database::rows::meta_id_from_row_id;\nuse collab_database::rows::Cell;\nuse collab_database::rows::DatabaseRowBody;\nuse collab_database::rows::RowDetail;\nuse collab_database::rows::RowId;\nuse collab_database::rows::RowMetaKey;\nuse collab_database::template::timestamp_parse::TimestampCellData;\nuse collab_database::workspace_database::WorkspaceDatabaseBody;\nuse collab_document::document::Document;\nuse collab_document::importer::md_importer::MDImporter;\nuse collab_entity::CollabType;\nuse collab_entity::EncodedCollab;\nuse collab_folder::CollabOrigin;\nuse database::collab::select_workspace_database_oid;\nuse database::collab::CollabStore;\nuse database::collab::GetCollabOrigin;\nuse database_entity::dto::QueryCollab;\nuse database_entity::dto::QueryCollabResult;\nuse rayon::iter::IntoParallelIterator;\nuse rayon::iter::ParallelIterator;\nuse sqlx::PgPool;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::sync::Arc;\nuse tracing::{instrument, trace};\nuse uuid::Uuid;\nuse yrs::block::ClientID;\nuse yrs::Map;\n\npub const DEFAULT_SPACE_ICON: &str = \"interface_essential/home-3\";\npub const DEFAULT_SPACE_ICON_COLOR: &str = \"0xFFA34AFD\";\n\n#[instrument(level = \"debug\", skip_all)]\npub fn get_row_details_serde(\n  row_detail: RowDetail,\n  field_by_id_name_uniq: &HashMap<String, Field>,\n  type_option_reader_by_id: &HashMap<String, Box<dyn TypeOptionCellReader>>,\n) -> HashMap<String, serde_json::Value> {\n  let mut cells = row_detail.row.cells;\n  let mut row_details_serde: HashMap<String, serde_json::Value> =\n    HashMap::with_capacity(cells.len());\n\n  trace!(\n    \"get_row_details_serde: row_id: {}, cells: {:#?}, field_by_id_name_uniq: {:#?}\",\n    row_detail.row.id,\n    cells,\n    field_by_id_name_uniq\n  );\n  for (field_id, field) in field_by_id_name_uniq {\n    let cell: Cell = match cells.remove(field_id) {\n      Some(cell) => cell.clone(),\n      None => {\n        let field_type = FieldType::from(field.field_type);\n        match field_type {\n          FieldType::CreatedTime => {\n            TimestampCellData::new(Some(row_detail.row.created_at)).to_cell(field_type)\n          },\n          FieldType::LastEditedTime => {\n            TimestampCellData::new(Some(row_detail.row.modified_at)).to_cell(field_type)\n          },\n          _ => Cell::new(),\n        }\n      },\n    };\n    let cell_value = match type_option_reader_by_id.get(&field.id) {\n      Some(tor) => tor.json_cell(&cell),\n      None => {\n        tracing::error!(\"Failed to get type option reader by id: {}\", field.id);\n        serde_json::Value::Null\n      },\n    };\n    row_details_serde.insert(field.name.clone(), cell_value);\n  }\n\n  row_details_serde\n}\n\n/// create a map of field name to field\n/// if the field name is repeated, it will be appended with the field id,\npub fn field_by_name_uniq(mut fields: Vec<Field>) -> HashMap<String, Field> {\n  fields.sort_by_key(|a| a.id.clone());\n  let mut uniq_name_set: HashSet<String> = HashSet::with_capacity(fields.len());\n  let mut field_by_name: HashMap<String, Field> = HashMap::with_capacity(fields.len());\n\n  for field in fields {\n    // if the name already exists, append the field id to the name\n    let name = if uniq_name_set.contains(&field.name) {\n      format!(\"{}-{}\", field.name, field.id)\n    } else {\n      field.name.clone()\n    };\n    uniq_name_set.insert(name.clone());\n    field_by_name.insert(name, field);\n  }\n  field_by_name\n}\n\n/// create a map of field id to field name, and ensure that the field name is unique.\n/// if the field name is repeated, it will be appended with the field id,\n/// under practical usage circumstances, no other collision should occur\npub fn field_by_id_name_uniq(mut fields: Vec<Field>) -> HashMap<String, Field> {\n  fields.sort_by_key(|a| a.id.clone());\n  let mut uniq_name_set: HashSet<String> = HashSet::with_capacity(fields.len());\n  let mut field_by_id: HashMap<String, Field> = HashMap::with_capacity(fields.len());\n\n  for mut field in fields {\n    // if the name already exists, append the field id to the name\n    if uniq_name_set.contains(&field.name) {\n      let new_name = format!(\"{}-{}\", field.name, field.id);\n      field.name.clone_from(&new_name);\n    }\n    uniq_name_set.insert(field.name.clone());\n    field_by_id.insert(field.id.clone(), field);\n  }\n  field_by_id\n}\n\n/// create a map type option writer by field id\npub fn type_option_writer_by_id(\n  fields: &[Field],\n) -> HashMap<String, Box<dyn TypeOptionCellWriter>> {\n  let mut type_option_reader_by_id: HashMap<String, Box<dyn TypeOptionCellWriter>> =\n    HashMap::with_capacity(fields.len());\n  for field in fields {\n    let field_id: String = field.id.clone();\n    let type_option_reader: Box<dyn TypeOptionCellWriter> = {\n      let field_type: &FieldType = &FieldType::from(field.field_type);\n      let type_option_data: TypeOptionData = match field.get_any_type_option(field_type.type_id()) {\n        Some(tod) => tod.clone(),\n        None => HashMap::new(),\n      };\n      type_option_cell_writer(type_option_data, field_type)\n    };\n    type_option_reader_by_id.insert(field_id, type_option_reader);\n  }\n  type_option_reader_by_id\n}\n\n/// create a map type option reader by field id\npub fn type_option_reader_by_id(\n  fields: &[Field],\n) -> HashMap<String, Box<dyn TypeOptionCellReader>> {\n  let mut type_option_reader_by_id: HashMap<String, Box<dyn TypeOptionCellReader>> =\n    HashMap::with_capacity(fields.len());\n  for field in fields {\n    let field_id: String = field.id.clone();\n    let type_option_reader: Box<dyn TypeOptionCellReader> = {\n      let field_type: &FieldType = &FieldType::from(field.field_type);\n      let type_option_data: TypeOptionData = match field.get_any_type_option(field_type.type_id()) {\n        Some(tod) => tod.clone(),\n        None => HashMap::new(),\n      };\n      type_option_cell_reader(type_option_data, field_type)\n    };\n    type_option_reader_by_id.insert(field_id, type_option_reader);\n  }\n  type_option_reader_by_id\n}\n\npub fn type_options_serde(\n  type_options: &TypeOptions,\n  field_type: &FieldType,\n) -> HashMap<String, serde_json::Value> {\n  let type_option = match type_options.get(&field_type.type_id()) {\n    Some(type_option) => type_option,\n    None => return HashMap::new(),\n  };\n\n  let mut result = HashMap::with_capacity(type_option.len());\n  for (key, value) in type_option {\n    match field_type {\n      FieldType::SingleSelect | FieldType::MultiSelect | FieldType::Media => {\n        // Certain type option are stored as stringified JSON\n        // We need to parse them back to JSON\n        // e.g. \"{ \\\"key\\\": \\\"value\\\" }\" -> { \"key\": \"value\" }\n        if let yrs::Any::String(arc_str) = value {\n          if let Ok(serde_value) = serde_json::from_str::<serde_json::Value>(arc_str) {\n            result.insert(key.clone(), serde_value);\n          }\n        }\n      },\n      _ => {\n        result.insert(key.clone(), serde_json::to_value(value).unwrap_or_default());\n      },\n    }\n  }\n\n  result\n}\n\npub async fn get_latest_collab_database_row_body(\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  db_row_id: Uuid,\n) -> Result<(Collab, DatabaseRowBody), AppError> {\n  let mut db_row_collab = get_latest_collab(\n    collab_storage,\n    GetCollabOrigin::Server,\n    workspace_id,\n    db_row_id,\n    CollabType::DatabaseRow,\n    default_client_id(),\n  )\n  .await?;\n\n  let row_id: RowId = db_row_id.to_string().into();\n  let db_row_body = DatabaseRowBody::open(row_id, &mut db_row_collab).map_err(|err| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to create database row body from collab, db_row_id: {}, err: {}\",\n      db_row_id,\n      err\n    ))\n  })?;\n\n  Ok((db_row_collab, db_row_body))\n}\n\npub async fn get_latest_collab_database_body(\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  database_id: Uuid,\n) -> Result<(Collab, DatabaseBody), AppError> {\n  let db_collab = get_latest_collab(\n    collab_storage,\n    GetCollabOrigin::Server,\n    workspace_id,\n    database_id,\n    CollabType::Database,\n    default_client_id(),\n  )\n  .await?;\n\n  tokio::task::spawn_blocking(move || {\n    let db_body = DatabaseBody::from_collab(\n      &db_collab,\n      Arc::new(NoPersistenceDatabaseCollabService::new(default_client_id())),\n      None,\n    )\n    .ok_or_else(|| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to create database body from collab, db_collab_id: {}\",\n        database_id,\n      ))\n    })?;\n    Ok((db_collab, db_body))\n  })\n  .await?\n}\n\n#[instrument(level = \"trace\", skip_all)]\npub async fn get_latest_collab(\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_origin: GetCollabOrigin,\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  client_id: ClientID,\n) -> Result<Collab, AppError> {\n  let encode_collab = collab_storage\n    .get_full_encode_collab(collab_origin, &workspace_id, &object_id, collab_type)\n    .await\n    .map(|v| v.encoded_collab)?;\n  let options =\n    CollabOptions::new(object_id.to_string(), client_id).with_data_source(encode_collab.into());\n  let collab = Collab::new_with_options(CollabOrigin::Server, options)\n    .map_err(|e| AppError::Unhandled(e.to_string()))?;\n  Ok(collab)\n}\n\npub async fn batch_get_latest_collab_encoded(\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_origin: GetCollabOrigin,\n  workspace_id: Uuid,\n  oid_list: &[Uuid],\n  collab_type: CollabType,\n) -> Result<HashMap<Uuid, EncodedCollab>, AppError> {\n  let uid = match collab_origin {\n    GetCollabOrigin::User { uid } => uid,\n    _ => 0,\n  };\n  let queries: Vec<QueryCollab> = oid_list\n    .iter()\n    .map(|row_id| QueryCollab {\n      object_id: *row_id,\n      collab_type,\n    })\n    .collect();\n  let query_collab_results = collab_storage\n    .batch_get_collab(&uid, workspace_id, queries)\n    .await;\n  let encoded_collabs = tokio::task::spawn_blocking(move || {\n    let collabs: HashMap<_, EncodedCollab> = query_collab_results\n      .into_par_iter()\n      .filter_map(|(oid, query_collab_result)| match query_collab_result {\n        QueryCollabResult::Success { encode_collab_v1 } => {\n          let decoded_result = EncodedCollab::decode_from_bytes(&encode_collab_v1);\n          match decoded_result {\n            Ok(decoded) => Some((oid, decoded)),\n            Err(err) => {\n              tracing::error!(\"Failed to decode collab for row {}: {}\", oid, err);\n              None\n            },\n          }\n        },\n        QueryCollabResult::Failed { error } => {\n          tracing::error!(\"Failed to get collab: {:?}\", error);\n          None\n        },\n      })\n      .collect();\n    collabs\n  })\n  .await?;\n  Ok(encoded_collabs)\n}\n\npub async fn get_latest_collab_workspace_database_body(\n  pg_pool: &PgPool,\n  storage: &Arc<dyn CollabStore>,\n  origin: GetCollabOrigin,\n  workspace_id: Uuid,\n) -> Result<WorkspaceDatabaseBody, AppError> {\n  let ws_db_oid = select_workspace_database_oid(pg_pool, &workspace_id).await?;\n  let mut collab = get_latest_collab(\n    storage,\n    origin,\n    workspace_id,\n    ws_db_oid,\n    CollabType::WorkspaceDatabase,\n    default_client_id(),\n  )\n  .await?;\n  let ws_db = WorkspaceDatabaseBody::open(&mut collab).map_err(|err| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to open workspace database body: {}\",\n      err\n    ))\n  })?;\n  Ok(ws_db)\n}\n\npub const DUMMY_UID: i64 = 0;\npub async fn get_latest_collab_document(\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_origin: GetCollabOrigin,\n  workspace_id: Uuid,\n  doc_oid: Uuid,\n) -> Result<Document, AppError> {\n  let doc_collab = get_latest_collab(\n    collab_storage,\n    collab_origin,\n    workspace_id,\n    doc_oid,\n    CollabType::Document,\n    default_client_id(),\n  )\n  .await?;\n  Document::open(doc_collab).map_err(|e| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to create document body from collab, doc_oid: {}, {}\",\n      doc_oid,\n      e\n    ))\n  })\n}\n\npub async fn collab_to_bin(collab: Collab, collab_type: CollabType) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || {\n    let bin = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .map_err(|e| AppError::Unhandled(e.to_string()))?\n      .encode_to_bytes()?;\n    Ok(bin)\n  })\n  .await?\n}\n\npub async fn collab_to_doc_state(\n  collab: Collab,\n  collab_type: CollabType,\n) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || {\n    let bin = collab\n      .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n      .map_err(|e| AppError::Unhandled(e.to_string()))?\n      .doc_state\n      .to_vec();\n    Ok(bin)\n  })\n  .await?\n}\n\npub fn collab_from_doc_state(\n  doc_state: Vec<u8>,\n  object_id: &Uuid,\n  client_id: ClientID,\n) -> Result<Collab, AppError> {\n  let options = CollabOptions::new(object_id.to_string(), client_id)\n    .with_data_source(DataSource::DocStateV1(doc_state));\n  let collab = Collab::new_with_options(CollabOrigin::Server, options)\n    .map_err(|e| AppError::Unhandled(e.to_string()))?;\n  Ok(collab)\n}\n\n/// Base on values given by [cell_value_by_id], write to fields of DatabaseRowBody.\n/// Returns encoded collab updates to the database row\n#[instrument(level = \"debug\", skip_all)]\npub async fn write_to_database_row(\n  db_body: &DatabaseBody,\n  db_row_txn: &mut yrs::TransactionMut<'_>,\n  db_row_body: &DatabaseRowBody,\n  cell_value_by_id: HashMap<String, serde_json::Value>,\n  modified_ts: i64,\n) -> Result<(), AppError> {\n  let all_fields = db_body.fields.get_all_fields(db_row_txn);\n  let field_by_id = all_fields.iter().fold(HashMap::new(), |mut acc, field| {\n    acc.insert(field.id.clone(), field.clone());\n    acc\n  });\n  let type_option_reader_by_id = type_option_writer_by_id(&all_fields);\n  let field_by_name = field_by_name_uniq(all_fields);\n\n  // set last_modified\n  db_row_body.update(db_row_txn, |row_update| {\n    row_update.set_last_modified(modified_ts);\n  });\n\n  trace!(\n    \"insert {} cells, {} fields. values: {:#?}\",\n    cell_value_by_id.len(),\n    field_by_id.len(),\n    cell_value_by_id\n  );\n  // for each field given by user input, overwrite existing data\n  for (id, serde_val) in cell_value_by_id {\n    let field = match field_by_id.get(&id) {\n      Some(f) => f,\n      // try use field name if id not found\n      None => match field_by_name.get(&id) {\n        Some(f) => f,\n        None => {\n          tracing::warn!(\"Failed to get field by id or name for field: {}\", id);\n          continue;\n        },\n      },\n    };\n    let cell_writer = match type_option_reader_by_id.get(&field.id) {\n      Some(cell_writer) => cell_writer,\n      None => {\n        tracing::error!(\"Failed to get type option writer for field: {}\", field.id);\n        continue;\n      },\n    };\n    let new_cell: Cell = cell_writer.convert_json_to_cell(serde_val);\n    trace!(\n      \"Writing cell for field: {}, value: {:?}\",\n      field.id,\n      new_cell,\n    );\n    db_row_body.update(db_row_txn, |row_update| {\n      row_update.update_cells(|cells_update| {\n        cells_update.insert_cell(&field.id, new_cell);\n      });\n    });\n  }\n  Ok(())\n}\n\npub async fn create_row_document(\n  workspace_id: Uuid,\n  uid: i64,\n  new_doc_id: Uuid,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  row_doc_content: String,\n) -> Result<CreatedRowDocument, AppError> {\n  let md_importer = MDImporter::new(None);\n  let client_id = default_client_id();\n  let new_doc_id_str = new_doc_id.to_string();\n  let doc_data = md_importer\n    .import(&new_doc_id_str, row_doc_content)\n    .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to import markdown: {:?}\", e)))?;\n  let doc = Document::create(&new_doc_id_str, doc_data, client_id)\n    .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to create document: {:?}\", e)))?;\n  let doc_ec = doc.encode_collab().map_err(|e| {\n    AppError::Internal(anyhow::anyhow!(\"Failed to encode document collab: {:?}\", e))\n  })?;\n\n  let mut folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let folder_updates = {\n    let mut folder_txn = folder.collab.transact_mut();\n    folder.body.views.insert(\n      &mut folder_txn,\n      collab_folder::View::orphan_view(\n        &new_doc_id_str,\n        collab_folder::ViewLayout::Document,\n        Some(uid),\n      ),\n      None,\n      uid,\n    );\n    folder_txn.encode_update_v1()\n  };\n\n  let doc_ec_bytes = doc_ec\n    .encode_to_bytes()\n    .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to encode db doc: {:?}\", e)))?;\n\n  Ok(CreatedRowDocument {\n    folder_updates,\n    doc_ec_bytes,\n  })\n}\n\npub enum DocChanges {\n  Update(Vec<u8>, Vec<u8>), // (updated_doc, doc_update)\n  Insert(CreatedRowDocument),\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn get_database_row_doc_changes(\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  workspace_id: Uuid,\n  row_doc_content: Option<String>,\n  db_row_body: &DatabaseRowBody,\n  db_row_txn: &mut yrs::TransactionMut<'_>,\n  row_id: &Uuid,\n  uid: i64,\n) -> Result<Option<(Uuid, DocChanges)>, AppError> {\n  let row_doc_content = match row_doc_content {\n    Some(row_doc_content) if !row_doc_content.is_empty() => row_doc_content,\n    _ => return Ok(None),\n  };\n\n  let doc_id = db_row_body\n    .document_id(db_row_txn)\n    .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to get document id: {:?}\", err)))?;\n\n  match doc_id {\n    Some(doc_id) => {\n      let doc_uuid = Uuid::parse_str(&doc_id)?;\n      let cur_doc = get_latest_collab_document(\n        collab_storage,\n        GetCollabOrigin::Server,\n        workspace_id,\n        doc_uuid,\n      )\n      .await?;\n\n      let md_importer = MDImporter::new(None);\n      let new_doc_data = md_importer\n        .import(&doc_id, row_doc_content)\n        .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to import markdown: {:?}\", e)))?;\n      let new_doc = Document::create(&doc_id, new_doc_data, default_client_id())\n        .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to create document: {:?}\", e)))?;\n\n      // if the document content is the same, there is no need to update\n      if cur_doc.paragraphs() == new_doc.paragraphs() {\n        return Ok(None);\n      };\n\n      let (mut cur_doc_collab, mut cur_doc_body) = cur_doc.split();\n\n      let doc_update = {\n        let mut txn = cur_doc_collab.context.transact_mut();\n        let new_doc_data = new_doc.get_document_data().map_err(|e| {\n          AppError::Internal(anyhow::anyhow!(\"Failed to get document data: {:?}\", e))\n        })?;\n        cur_doc_body\n          .reset_with_data(&mut txn, Some(new_doc_data))\n          .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to reset document: {:?}\", e)))?;\n        txn.encode_update_v1()\n      };\n\n      // Clear undo manager state to save space\n      if let Ok(undo_mgr) = cur_doc_collab.undo_manager_mut() {\n        undo_mgr.clear();\n      }\n\n      let updated_doc = collab_to_bin(cur_doc_collab, CollabType::Document).await?;\n      Ok(Some((\n        doc_uuid,\n        DocChanges::Update(updated_doc, doc_update),\n      )))\n    },\n    None => {\n      // update row to indicate that the document is not empty\n      let is_document_empty_id = meta_id_from_row_id(row_id, RowMetaKey::IsDocumentEmpty);\n      db_row_body\n        .get_meta()\n        .insert(db_row_txn, is_document_empty_id, false);\n\n      // get document id\n      let new_doc_id = db_row_body\n        .document_id(db_row_txn)\n        .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to get document id: {:?}\", err)))?\n        .ok_or_else(|| AppError::Internal(anyhow::anyhow!(\"Failed to get document id\")))?;\n\n      let new_doc_id = Uuid::parse_str(&new_doc_id)?;\n      let created_row_doc: CreatedRowDocument = create_row_document(\n        workspace_id,\n        uid,\n        new_doc_id,\n        collab_instance_cache,\n        row_doc_content,\n      )\n      .await?;\n      Ok(Some((new_doc_id, DocChanges::Insert(created_row_doc))))\n    },\n  }\n}\n\npub struct CreatedRowDocument {\n  // pub updated_folder: Vec<u8>,\n  pub folder_updates: Vec<u8>,\n  pub doc_ec_bytes: Vec<u8>,\n}\n"
  },
  {
    "path": "src/biz/data_import/mod.rs",
    "content": "use actix_multipart::Multipart;\nuse anyhow::Result;\nuse async_zip::base::write::ZipFileWriter;\nuse async_zip::{Compression, ZipEntryBuilder};\nuse futures_lite::{AsyncWriteExt, StreamExt};\nuse std::path::PathBuf;\nuse tokio::fs::File;\nuse tokio_util::compat::TokioAsyncWriteCompatExt;\nuse uuid::Uuid;\n\nuse actix_web::web::Payload;\nuse app_error::AppError;\nuse bytes::Bytes;\nuse futures::Stream;\nuse std::pin::Pin;\nuse std::task::{Context, Poll};\n\n#[allow(dead_code)]\npub async fn create_archive(\n  mut body: Multipart,\n  file_path: &PathBuf,\n) -> Result<(String, usize), anyhow::Error> {\n  let mut file_name = \"\".to_string();\n  let mut file_size = 0;\n\n  let archive = File::create(file_path).await?.compat_write();\n  let mut writer = ZipFileWriter::new(archive);\n\n  while let Some(Ok(mut field)) = body.next().await {\n    let name = match field.content_disposition().and_then(|c| c.get_filename()) {\n      Some(filename) => sanitize_filename::sanitize(filename),\n      None => Uuid::new_v4().to_string(),\n    };\n\n    if file_name.is_empty() {\n      file_name = field\n        .content_disposition()\n        .and_then(|c| c.get_name().map(|f| f.to_string()))\n        .unwrap_or_else(|| format!(\"import-{}\", chrono::Local::now().format(\"%d/%m/%Y %H:%M\")));\n    }\n\n    // Build the zip entry\n    let builder = ZipEntryBuilder::new(name.into(), Compression::Deflate);\n    let mut entry_writer = writer.write_entry_stream(builder).await?;\n    while let Some(Ok(chunk)) = field.next().await {\n      file_size += chunk.len();\n      entry_writer.write_all(&chunk).await?;\n    }\n    entry_writer.close().await?;\n  }\n  writer.close().await?;\n  Ok((file_name, file_size))\n}\n\npub struct LimitedPayload {\n  payload: Payload,\n  remaining: usize,\n}\n\nimpl LimitedPayload {\n  pub fn new(payload: Payload, limit: usize) -> Self {\n    LimitedPayload {\n      payload,\n      remaining: limit,\n    }\n  }\n}\n\nimpl Stream for LimitedPayload {\n  type Item = Result<Bytes, AppError>;\n\n  fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {\n    if self.remaining == 0 {\n      // If there's remaining data in the payload, it's larger than expected\n      if futures::ready!(Pin::new(&mut self.payload).poll_next(cx)).is_some() {\n        return Poll::Ready(Some(Err(AppError::PayloadTooLarge(\n          \"Content length exceeded\".into(),\n        ))));\n      }\n      return Poll::Ready(None);\n    }\n\n    match Pin::new(&mut self.payload).poll_next(cx) {\n      Poll::Ready(Some(Ok(chunk))) => {\n        let chunk_size = chunk.len();\n        if chunk_size > self.remaining {\n          return Poll::Ready(Some(Err(AppError::PayloadTooLarge(\n            \"Content length exceeded\".into(),\n          ))));\n        }\n        self.remaining -= chunk_size;\n        Poll::Ready(Some(Ok(chunk)))\n      },\n      Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(AppError::Internal(anyhow::anyhow!(e))))),\n      Poll::Ready(None) => {\n        if self.remaining > 0 {\n          return Poll::Ready(Some(Err(AppError::InvalidRequest(\n            \"Content shorter than Content-Length\".into(),\n          ))));\n        }\n        Poll::Ready(None)\n      },\n      Poll::Pending => Poll::Pending,\n    }\n  }\n}\n"
  },
  {
    "path": "src/biz/mod.rs",
    "content": "pub mod access_request;\npub mod authentication;\npub mod chat;\npub mod collab;\npub mod data_import;\npub mod notification;\npub mod pg_listener;\npub mod search;\npub mod template;\npub mod user;\npub mod workspace;\n"
  },
  {
    "path": "src/biz/notification/email.rs",
    "content": "use std::time::Duration;\n\nuse database::notification::{\n  select_recent_page_mentions, update_page_mention_notification_status,\n};\nuse database_entity::dto::ProcessedPageMentionNotification;\nuse sqlx::PgPool;\nuse tokio::time::interval;\n\nuse crate::mailer::{AFCloudMailer, PageMentionNotificationMailerParam};\n\npub struct EmailNotificationWorker {\n  pub pg_pool: PgPool,\n  pub mailer: AFCloudMailer,\n  pub notification_interval_seconds: u64,\n  pub notification_grace_period_seconds: u64,\n  pub appflowy_web_url: String,\n}\n\nimpl EmailNotificationWorker {\n  pub fn new(\n    pg_pool: PgPool,\n    mailer: AFCloudMailer,\n    notification_interval_seconds: u64,\n    notification_grace_period_seconds: u64,\n    appflowy_web_url: &str,\n  ) -> Self {\n    Self {\n      pg_pool,\n      mailer,\n      notification_interval_seconds,\n      notification_grace_period_seconds,\n      appflowy_web_url: appflowy_web_url.to_string(),\n    }\n  }\n\n  pub async fn start_task(&self) {\n    let mut interval = interval(Duration::from_secs(self.notification_interval_seconds));\n\n    loop {\n      interval.tick().await;\n      self.send_page_notification_emails().await;\n    }\n  }\n\n  async fn send_page_notification_emails(&self) {\n    let default_mentioner_avatar_url =\n      \"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png\"\n        .to_string();\n\n    match select_recent_page_mentions(\n      &self.pg_pool,\n      self.notification_interval_seconds,\n      self.notification_grace_period_seconds,\n    )\n    .await\n    {\n      Ok(page_mentions) => {\n        let processed_mentions: Vec<ProcessedPageMentionNotification> = page_mentions\n          .iter()\n          .map(|pm| ProcessedPageMentionNotification {\n            view_id: pm.view_id,\n            person_id: pm.mentioned_person_id,\n          })\n          .collect();\n\n        for mention in page_mentions {\n          let mut page_url = format!(\n            \"{}/app/{}/{}\",\n            self.appflowy_web_url, mention.workspace_id, mention.view_id\n          );\n          if let Some(block_id) = mention.block_id {\n            page_url.push_str(&format!(\"?blockId={}\", block_id));\n          }\n\n          let param = PageMentionNotificationMailerParam {\n            workspace_name: mention.workspace_name,\n            mentioned_page_name: mention.view_name,\n            mentioner_icon_url: default_mentioner_avatar_url.clone(),\n            mentioner_name: mention.mentioner_name,\n            mentioned_page_url: format!(\n              \"{}/app/{}/{}\",\n              self.appflowy_web_url, mention.workspace_id, mention.view_id\n            ),\n            mentioned_at: mention\n              .mentioned_at\n              .with_timezone(&chrono::Utc)\n              .format(\"%b %d, %Y, %-I:%M %p (UTC)\")\n              .to_string(),\n          };\n\n          if let Err(err) = self\n            .mailer\n            .send_page_mention_notification(\n              &mention.mentioned_person_name,\n              &mention.mentioned_person_email,\n              &param,\n            )\n            .await\n          {\n            tracing::error!(\n              \"Failed to send page mention notification email to {}: {}\",\n              &mention.mentioned_person_email,\n              err\n            );\n          } else {\n            tracing::debug!(\n              \"Sent page mention notification email to {}\",\n              &mention.mentioned_person_email\n            );\n          }\n        }\n\n        let update_result =\n          update_page_mention_notification_status(&self.pg_pool, &processed_mentions).await;\n        if update_result.is_err() {\n          tracing::warn!(\n            \"Failed to update page mention notification status: {:?}\",\n            update_result.err()\n          );\n        } else {\n          tracing::debug!(\"Successfully updated page mention notification status\");\n        }\n      },\n      Err(err) => {\n        tracing::warn!(\"Failed to get recent page mention updates: {:?}\", err);\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "src/biz/notification/mod.rs",
    "content": "pub mod email;\n"
  },
  {
    "path": "src/biz/pg_listener.rs",
    "content": "use anyhow::Error;\nuse database::listener::PostgresDBListener;\nuse database::pg_row::AFUserNotification;\nuse sqlx::PgPool;\n\npub struct PgListeners {\n  user_listener: UserListener,\n}\n\nimpl PgListeners {\n  pub async fn new(pg_pool: &PgPool) -> Result<Self, Error> {\n    let user_listener = UserListener::new(pg_pool, \"af_user_channel\").await?;\n    Ok(Self { user_listener })\n  }\n\n  pub fn subscribe_user_change(&self, uid: i64) -> tokio::sync::mpsc::Receiver<AFUserNotification> {\n    let (tx, rx) = tokio::sync::mpsc::channel(100);\n    let mut user_notify = self.user_listener.notify.subscribe();\n    tokio::spawn(async move {\n      while let Ok(notification) = user_notify.recv().await {\n        if let Some(row) = notification.payload.as_ref() {\n          if row.uid == uid {\n            let _ = tx.send(notification).await;\n          }\n        }\n      }\n    });\n    rx\n  }\n}\n\npub type UserListener = PostgresDBListener<AFUserNotification>;\n"
  },
  {
    "path": "src/biz/search/mod.rs",
    "content": "mod ops;\n\npub use self::ops::*;\n"
  },
  {
    "path": "src/biz/search/ops.rs",
    "content": "use crate::biz::collab::folder_view::PrivateSpaceAndTrashViews;\nuse crate::{\n  api::metrics::RequestMetrics, biz::collab::folder_view::private_space_and_trash_view_ids,\n};\nuse app_error::AppError;\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse appflowy_collaborate::ws2::WorkspaceCollabInstanceCache;\nuse collab_folder::{Folder, View};\nuse database::index::{search_documents, SearchDocumentParams};\nuse indexer::scheduler::IndexerScheduler;\nuse indexer::vector::embedder::{CreateEmbeddingRequestArgs, EmbeddingInput, EncodingFormat};\nuse infra::env_util::get_env_var;\nuse llm_client::chat::{AITool, LLMDocument};\nuse shared_entity::dto::search_dto::{\n  SearchContentType, SearchDocumentRequest, SearchDocumentResponseItem, SearchSummaryResult,\n  Summary, SummarySearchResultRequest,\n};\nuse sqlx::PgPool;\nuse std::collections::HashSet;\nuse std::sync::Arc;\nuse tracing::{error, trace};\nuse uuid::Uuid;\n\nstatic MAX_SEARCH_DEPTH: i32 = 10;\n\nfn is_view_searchable(view: &View, workspace_id: &str) -> bool {\n  view.id != workspace_id && view.parent_view_id != workspace_id && view.layout.is_document()\n}\n\n#[allow(clippy::too_many_arguments)]\nfn populate_searchable_view_ids(\n  folder: &Folder,\n  private_space_and_trash_views: &PrivateSpaceAndTrashViews,\n  searchable_view_ids: &mut HashSet<Uuid>,\n  workspace_id: &Uuid,\n  current_view_id: &Uuid,\n  depth: i32,\n  max_depth: i32,\n  uid: i64,\n) {\n  if depth > max_depth {\n    return;\n  }\n  let is_other_private_space = private_space_and_trash_views\n    .other_private_space_ids\n    .contains(current_view_id);\n  let is_trash = private_space_and_trash_views\n    .view_ids_in_trash\n    .contains(current_view_id);\n  if is_other_private_space || is_trash {\n    return;\n  }\n  let view = match folder.get_view(&current_view_id.to_string(), uid) {\n    Some(view) => view,\n    None => return,\n  };\n\n  if is_view_searchable(&view, &workspace_id.to_string()) {\n    searchable_view_ids.insert(*current_view_id);\n  }\n  for child in view.children.iter() {\n    let child_id = Uuid::parse_str(&child.id).unwrap();\n    populate_searchable_view_ids(\n      folder,\n      private_space_and_trash_views,\n      searchable_view_ids,\n      workspace_id,\n      &child_id,\n      depth + 1,\n      max_depth,\n      uid,\n    );\n  }\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn search_document(\n  pg_pool: &PgPool,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  indexer_scheduler: &Arc<IndexerScheduler>,\n  uid: i64,\n  workspace_uuid: Uuid,\n  request: SearchDocumentRequest,\n  metrics: &RequestMetrics,\n) -> Result<Vec<SearchDocumentResponseItem>, AppError> {\n  // Set up the embedding model and create an embedding request.\n  let default_model = EmbeddingModel::default_model();\n  let embeddings_request = CreateEmbeddingRequestArgs::default()\n    .model(default_model.to_string())\n    .input(EmbeddingInput::String(request.query.clone()))\n    .encoding_format(EncodingFormat::Float)\n    .dimensions(default_model.default_dimensions())\n    .build()\n    .map_err(|err| AppError::Unhandled(err.to_string()))?;\n\n  // Create embeddings using the indexer scheduler.\n  let mut embeddings_resp = indexer_scheduler\n    .create_search_embeddings(embeddings_request)\n    .await?;\n  let total_tokens = embeddings_resp.usage.total_tokens;\n  metrics.record_search_tokens_used(&workspace_uuid, total_tokens);\n  tracing::info!(\n    \"workspace {} OpenAI API search tokens used: {}\",\n    workspace_uuid,\n    total_tokens\n  );\n\n  // Extract the embedding from the response.\n  let embedding = embeddings_resp\n    .data\n    .pop()\n    .ok_or_else(|| AppError::Internal(anyhow::anyhow!(\"OpenAI returned no embeddings\")))?;\n\n  // Obtain the latest collab folder and gather searchable view IDs.\n  let folder = collab_instance_cache.get_folder(workspace_uuid).await?;\n  let private_views = private_space_and_trash_view_ids(uid, &folder)?;\n  let mut searchable_view_ids = HashSet::new();\n  populate_searchable_view_ids(\n    &folder,\n    &private_views,\n    &mut searchable_view_ids,\n    &workspace_uuid,\n    &workspace_uuid,\n    0,\n    MAX_SEARCH_DEPTH,\n    uid,\n  );\n\n  // Set default preview size and search parameters.\n  let preview_size = request.preview_size.unwrap_or(500) as i32;\n  let params = SearchDocumentParams {\n    user_id: uid,\n    workspace_id: workspace_uuid,\n    limit: request.limit.unwrap_or(10) as i32,\n    preview: preview_size,\n    embedding: embedding.embedding,\n    searchable_view_ids: searchable_view_ids.into_iter().collect(),\n    score: request.score,\n  };\n\n  trace!(\n    \"[Search] query: {}, limit: {}, score: {:?}, workspace: {}\",\n    request.query,\n    params.limit,\n    params.score,\n    params.workspace_id,\n  );\n\n  // Perform document search.\n  let results = search_documents(pg_pool, params, total_tokens).await?;\n  trace!(\n    \"[Search] query:{}, got {} results\",\n    request.query,\n    results.len(),\n  );\n\n  // Build and return the search result, mapping each document to its response item.\n  let items = results\n    .into_iter()\n    .map(|item| SearchDocumentResponseItem {\n      object_id: item.object_id,\n      workspace_id: item.workspace_id,\n      score: item.score,\n      content_type: SearchContentType::from_record(item.content_type),\n      preview: Some(item.content.chars().take(preview_size as usize).collect()),\n      created_by: item.created_by,\n      created_at: item.created_at,\n      content: item.content,\n    })\n    .collect();\n\n  Ok(items)\n}\n\npub async fn summarize_search_results(\n  ai_tool: Option<AITool>,\n  request: SummarySearchResultRequest,\n) -> Result<SearchSummaryResult, AppError> {\n  if request.search_results.is_empty() {\n    return Ok(SearchSummaryResult { summaries: vec![] });\n  }\n\n  if ai_tool.is_none() {\n    return Err(AppError::FeatureNotAvailable(\n      \"AI tool is not available\".to_string(),\n    ));\n  }\n\n  let ai_tool = ai_tool.unwrap();\n  let model_name = get_env_var(\"AI_OPENAI_API_SUMMARY_MODEL\", \"gpt-4.1-nano\");\n\n  let mut summaries = Vec::new();\n  let SummarySearchResultRequest {\n    query,\n    search_results,\n    only_context,\n  } = request;\n\n  let llm_docs: Vec<LLMDocument> = search_results\n    .into_iter()\n    .map(|result| LLMDocument::new(result.content, result.object_id))\n    .collect();\n\n  trace!(\n    \"[Search] use {} model to summarize search result docs: {:#?}\",\n    model_name,\n    llm_docs,\n  );\n  match ai_tool\n    .summarize_documents(&query, &model_name, llm_docs, only_context)\n    .await\n  {\n    Ok(resp) => {\n      trace!(\"AI summary search document response: {:?}\", resp);\n      summaries = resp\n        .summaries\n        .into_iter()\n        .map(|s| Summary {\n          content: s.content,\n          sources: s.sources,\n          highlights: s.highlights,\n        })\n        .collect();\n    },\n    Err(err) => error!(\"AI summary search document failed, error: {:?}\", err),\n  }\n\n  Ok(SearchSummaryResult { summaries })\n}\n"
  },
  {
    "path": "src/biz/template/mod.rs",
    "content": "pub mod ops;\n"
  },
  {
    "path": "src/biz/template/ops.rs",
    "content": "use std::{\n  collections::{HashMap, HashSet},\n  ops::DerefMut,\n  path::Path,\n};\n\nuse actix_multipart::form::bytes::Bytes as MPBytes;\nuse anyhow::Context;\nuse app_error::ErrorCode;\nuse aws_sdk_s3::primitives::ByteStream;\nuse database::{\n  file::{s3_client_impl::AwsS3BucketClientImpl, BucketClient, ResponseBlob},\n  publish::{select_publish_info_for_view_ids, select_published_collab_info},\n  template::*,\n};\nuse database_entity::dto::{\n  AccountLink, AvatarContent, PublishInfo, Template, TemplateCategory, TemplateCategoryType,\n  TemplateCreator, TemplateGroupWithPublishInfo, TemplateHomePage, TemplateMinimalWithPublishInfo,\n  TemplateWithPublishInfo,\n};\nuse shared_entity::response::AppResponseError;\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\npub async fn create_new_template_category(\n  pg_pool: &PgPool,\n  name: &str,\n  description: &str,\n  icon: &str,\n  bg_color: &str,\n  category_type: TemplateCategoryType,\n  rank: i32,\n) -> Result<TemplateCategory, AppResponseError> {\n  let new_template_category = insert_new_template_category(\n    pg_pool,\n    name,\n    description,\n    icon,\n    bg_color,\n    category_type,\n    rank,\n  )\n  .await?;\n  Ok(new_template_category)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_template_category(\n  pg_pool: &PgPool,\n  category_id: Uuid,\n  name: &str,\n  description: &str,\n  icon: &str,\n  bg_color: &str,\n  category_type: TemplateCategoryType,\n  rank: i32,\n) -> Result<TemplateCategory, AppResponseError> {\n  let updated_template_category = update_template_category_by_id(\n    pg_pool,\n    category_id,\n    name,\n    description,\n    icon,\n    bg_color,\n    category_type,\n    rank,\n  )\n  .await?;\n  Ok(updated_template_category)\n}\n\npub async fn get_template_categories(\n  pg_pool: &PgPool,\n  name_contains: Option<&str>,\n  category_type: Option<TemplateCategoryType>,\n) -> Result<Vec<TemplateCategory>, AppResponseError> {\n  let categories = select_template_categories(pg_pool, name_contains, category_type).await?;\n  Ok(categories)\n}\n\npub async fn get_template_category(\n  pg_pool: &PgPool,\n  category_id: Uuid,\n) -> Result<TemplateCategory, AppResponseError> {\n  let category = select_template_category_by_id(pg_pool, category_id).await?;\n  Ok(category)\n}\n\npub async fn delete_template_category(\n  pg_pool: &PgPool,\n  category_id: Uuid,\n) -> Result<(), AppResponseError> {\n  delete_template_category_by_id(pg_pool, category_id).await?;\n  Ok(())\n}\n\npub async fn create_new_template_creator(\n  pg_pool: &PgPool,\n  name: &str,\n  avatar_url: &str,\n  account_links: &[AccountLink],\n) -> Result<TemplateCreator, AppResponseError> {\n  let new_template_creator =\n    insert_template_creator(pg_pool, name, avatar_url, account_links).await?;\n  Ok(new_template_creator)\n}\n\npub async fn update_template_creator(\n  pg_pool: &PgPool,\n  creator_id: Uuid,\n  name: &str,\n  avatar_url: &str,\n  account_links: &[AccountLink],\n) -> Result<TemplateCreator, AppResponseError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to update template creator\")?;\n  delete_template_creator_account_links(txn.deref_mut(), creator_id).await?;\n  let updated_template_creator =\n    update_template_creator_by_id(txn.deref_mut(), creator_id, name, avatar_url, account_links)\n      .await?;\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to update template creator\")?;\n  Ok(updated_template_creator)\n}\n\npub async fn get_template_creators(\n  pg_pool: &PgPool,\n  keyword: &Option<String>,\n) -> Result<Vec<TemplateCreator>, AppResponseError> {\n  let substr_match = keyword.as_deref().unwrap_or(\"%\");\n  let creators = select_template_creators_by_name(pg_pool, substr_match).await?;\n  Ok(creators)\n}\n\npub async fn get_template_creator(\n  pg_pool: &PgPool,\n  creator_id: Uuid,\n) -> Result<TemplateCreator, AppResponseError> {\n  let creator = select_template_creator_by_id(pg_pool, creator_id).await?;\n  Ok(creator)\n}\n\npub async fn delete_template_creator(\n  pg_pool: &PgPool,\n  creator_id: Uuid,\n) -> Result<(), AppResponseError> {\n  delete_template_creator_by_id(pg_pool, creator_id).await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_new_template(\n  pg_pool: &PgPool,\n  view_id: Uuid,\n  name: &str,\n  description: &str,\n  about: &str,\n  view_url: &str,\n  creator_id: Uuid,\n  is_new_template: bool,\n  is_featured: bool,\n  category_ids: &[Uuid],\n  related_view_ids: &[Uuid],\n) -> Result<Template, AppResponseError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to create template creator\")?;\n  insert_template_view(\n    txn.deref_mut(),\n    view_id,\n    name,\n    description,\n    about,\n    view_url,\n    creator_id,\n    is_new_template,\n    is_featured,\n  )\n  .await?;\n  insert_template_view_template_category(txn.deref_mut(), view_id, category_ids).await?;\n  insert_related_templates(txn.deref_mut(), view_id, related_view_ids).await?;\n  let new_template = select_template_view_by_id(txn.deref_mut(), view_id).await?;\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to update template creator\")?;\n  Ok(new_template)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_template(\n  pg_pool: &PgPool,\n  view_id: Uuid,\n  name: &str,\n  description: &str,\n  about: &str,\n  view_url: &str,\n  creator_id: Uuid,\n  is_new_template: bool,\n  is_featured: bool,\n  category_ids: &[Uuid],\n  related_view_ids: &[Uuid],\n) -> Result<Template, AppResponseError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to update template\")?;\n  delete_template_view_template_categories(txn.deref_mut(), view_id).await?;\n  delete_related_templates(txn.deref_mut(), view_id).await?;\n  update_template_view(\n    txn.deref_mut(),\n    view_id,\n    name,\n    description,\n    about,\n    view_url,\n    creator_id,\n    is_new_template,\n    is_featured,\n  )\n  .await?;\n  insert_template_view_template_category(txn.deref_mut(), view_id, category_ids).await?;\n  insert_related_templates(txn.deref_mut(), view_id, related_view_ids).await?;\n  let updated_template = select_template_view_by_id(txn.deref_mut(), view_id).await?;\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to update template\")?;\n  Ok(updated_template)\n}\n\npub async fn get_templates_with_publish_info(\n  pg_pool: &PgPool,\n  category_id: Option<Uuid>,\n  is_featured: Option<bool>,\n  is_new_template: Option<bool>,\n  name_contains: Option<&str>,\n) -> Result<Vec<TemplateMinimalWithPublishInfo>, AppResponseError> {\n  let templates = select_templates(\n    pg_pool,\n    category_id,\n    is_featured,\n    is_new_template,\n    name_contains,\n    None,\n  )\n  .await?;\n  let view_ids = templates.iter().map(|t| t.view_id).collect::<Vec<Uuid>>();\n  let publish_info_for_views = select_publish_info_for_view_ids(pg_pool, &view_ids).await?;\n  let mut publish_info_map = publish_info_for_views\n    .into_iter()\n    .map(|info| (info.view_id, info))\n    .collect::<HashMap<Uuid, PublishInfo>>();\n  let templates_with_publish_info: Vec<TemplateMinimalWithPublishInfo> = templates\n    .into_iter()\n    .filter_map(|template| {\n      publish_info_map\n        .remove(&template.view_id)\n        .map(|publish_info| TemplateMinimalWithPublishInfo {\n          template,\n          publish_info,\n        })\n    })\n    .collect();\n  if templates_with_publish_info.len() != view_ids.len() {\n    return Err(AppResponseError::new(\n      ErrorCode::Internal,\n      \"one or more templates does not have a publish info\",\n    ));\n  }\n  Ok(templates_with_publish_info)\n}\n\npub async fn get_template_with_publish_info(\n  pg_pool: &PgPool,\n  view_id: Uuid,\n) -> Result<TemplateWithPublishInfo, AppResponseError> {\n  let template = select_template_view_by_id(pg_pool, view_id).await?;\n  let publish_info = select_published_collab_info(pg_pool, &view_id).await?;\n  let template_with_publish_info = TemplateWithPublishInfo {\n    template,\n    publish_info,\n  };\n  Ok(template_with_publish_info)\n}\n\npub async fn delete_template(pg_pool: &PgPool, view_id: Uuid) -> Result<(), AppResponseError> {\n  delete_template_by_view_id(pg_pool, view_id).await?;\n  Ok(())\n}\n\nconst DEFAULT_HOMEPAGE_CATEGORY_COUNT: i64 = 10;\n\npub async fn get_template_homepage(\n  pg_pool: &PgPool,\n  per_count: Option<i64>,\n) -> Result<TemplateHomePage, AppResponseError> {\n  let per_count = per_count.unwrap_or(DEFAULT_HOMEPAGE_CATEGORY_COUNT);\n  let template_groups = select_template_homepage(pg_pool, per_count).await?;\n  let featured_templates =\n    select_templates(pg_pool, None, Some(true), None, None, Some(per_count)).await?;\n  let new_templates =\n    select_templates(pg_pool, None, None, Some(true), None, Some(per_count)).await?;\n  let template_groups_view_ids = template_groups\n    .iter()\n    .flat_map(|group| group.templates.iter().map(|t| t.view_id))\n    .collect::<HashSet<Uuid>>();\n  let feature_templates_view_ids = featured_templates\n    .iter()\n    .map(|t| t.view_id)\n    .collect::<HashSet<Uuid>>();\n  let new_templates_view_ids = new_templates\n    .iter()\n    .map(|t| t.view_id)\n    .collect::<HashSet<Uuid>>();\n  let mut all_view_ids: HashSet<Uuid> = HashSet::new();\n  all_view_ids.extend(&template_groups_view_ids);\n  all_view_ids.extend(&feature_templates_view_ids);\n  all_view_ids.extend(&new_templates_view_ids);\n  let all_view_ids: Vec<Uuid> = all_view_ids.into_iter().collect();\n\n  let publish_info_for_views: Vec<PublishInfo> =\n    select_publish_info_for_view_ids(pg_pool, &all_view_ids).await?;\n  let publish_info_map = publish_info_for_views\n    .into_iter()\n    .map(|info| (info.view_id, info))\n    .collect::<HashMap<Uuid, PublishInfo>>();\n\n  let template_groups_with_publish_info = template_groups\n    .into_iter()\n    .map(|group| {\n      let templates_with_publish_info = group\n        .templates\n        .into_iter()\n        .filter_map(|template| {\n          publish_info_map.get(&template.view_id).map(|publish_info| {\n            TemplateMinimalWithPublishInfo {\n              template,\n              publish_info: publish_info.clone(),\n            }\n          })\n        })\n        .collect();\n      TemplateGroupWithPublishInfo {\n        category: group.category.clone(),\n        templates: templates_with_publish_info,\n      }\n    })\n    .collect();\n\n  let featured_templates_with_publish_info = featured_templates\n    .into_iter()\n    .filter_map(|template| {\n      publish_info_map\n        .get(&template.view_id)\n        .map(|publish_info| TemplateMinimalWithPublishInfo {\n          template,\n          publish_info: publish_info.clone(),\n        })\n    })\n    .collect();\n\n  let new_templates_with_publish_info = new_templates\n    .into_iter()\n    .filter_map(|template| {\n      publish_info_map\n        .get(&template.view_id)\n        .map(|publish_info| TemplateMinimalWithPublishInfo {\n          template,\n          publish_info: publish_info.clone(),\n        })\n    })\n    .collect();\n\n  let homepage = TemplateHomePage {\n    template_groups: template_groups_with_publish_info,\n    featured_templates: featured_templates_with_publish_info,\n    new_templates: new_templates_with_publish_info,\n  };\n  Ok(homepage)\n}\n\nfn avatar_object_key(file_id: &str) -> String {\n  format!(\"template-center/avatar/{}\", file_id)\n}\n\npub async fn get_avatar(\n  client: AwsS3BucketClientImpl,\n  file_id: String,\n) -> Result<AvatarContent, AppResponseError> {\n  let object_key = avatar_object_key(&file_id);\n  let resp = client.get_blob(&object_key).await?;\n  let content_type = resp.content_type().ok_or(AppResponseError::new(\n    ErrorCode::InvalidContentType,\n    \"Missing content type for avatar\".to_string(),\n  ))?;\n  Ok(AvatarContent {\n    data: resp.to_blob(),\n    content_type: content_type.to_string(),\n  })\n}\n\npub async fn upload_avatar(\n  client: AwsS3BucketClientImpl,\n  avatar: &MPBytes,\n) -> Result<String, AppResponseError> {\n  let content_type = match &avatar.content_type {\n    Some(content_type) if content_type.type_() == mime::IMAGE => Ok(content_type.to_string()),\n    Some(content_type) => Err(AppResponseError::new(\n      ErrorCode::InvalidContentType,\n      format!(\"Invalid mime type for avatar upload: {}\", content_type),\n    )),\n    None => Err(AppResponseError::new(\n      ErrorCode::InvalidContentType,\n      \"Missing mime type for avatar upload\",\n    )),\n  }?;\n  let file_name = avatar\n    .file_name\n    .as_ref()\n    .ok_or(AppResponseError::new(\n      ErrorCode::InvalidContentType,\n      \"Missing file name for avatar upload\",\n    ))?\n    .as_str();\n  let extension = Path::new(&file_name)\n    .extension()\n    .ok_or(AppResponseError::new(\n      ErrorCode::InvalidContentType,\n      \"Missing file extension for avatar upload\",\n    ))?\n    .to_str()\n    .ok_or(AppResponseError::new(\n      ErrorCode::InvalidContentType,\n      \"Invalid file extension for avatar upload\",\n    ))?;\n  let file_id = format!(\"{}.{}\", Uuid::new_v4(), extension);\n\n  let object_key = avatar_object_key(&file_id);\n  client\n    .put_blob_with_content_type(\n      &object_key,\n      ByteStream::from(avatar.data.to_vec()),\n      &content_type,\n    )\n    .await?;\n  Ok(file_id.to_string())\n}\n"
  },
  {
    "path": "src/biz/user/image_asset.rs",
    "content": "use std::path::Path;\n\nuse actix_multipart::form::bytes::Bytes;\nuse app_error::AppError;\nuse aws_sdk_s3::primitives::ByteStream;\nuse database::file::{s3_client_impl::AwsS3BucketClientImpl, BucketClient, ResponseBlob};\nuse database_entity::dto::UserImageAssetContent;\nuse uuid::Uuid;\n\nfn user_image_asset_object_key(person_id: &Uuid, file_id: &str) -> String {\n  format!(\"user-asset/image/{}/{}\", person_id, file_id)\n}\n\npub async fn upload_user_image_asset(\n  client: AwsS3BucketClientImpl,\n  image_content: &Bytes,\n  person_id: &Uuid,\n) -> Result<String, AppError> {\n  let content_type = match &image_content.content_type {\n    Some(content_type) if content_type.type_() == mime::IMAGE => Ok(content_type.to_string()),\n    Some(content_type) => Err(AppError::InvalidContentType(format!(\n      \"{} is not a valid image type\",\n      content_type\n    ))),\n    None => Err(AppError::InvalidContentType(\n      \"Missing mime type for image upload\".to_string(),\n    )),\n  }?;\n  let file_name = image_content\n    .file_name\n    .as_ref()\n    .ok_or(AppError::InvalidContentType(\n      \"Missing file name for image upload\".to_string(),\n    ))?\n    .as_str();\n  let extension = Path::new(&file_name)\n    .extension()\n    .ok_or(AppError::InvalidContentType(\n      \"Missing file extension for image upload\".to_string(),\n    ))?\n    .to_str()\n    .ok_or(AppError::InvalidContentType(\n      \"Invalid file extension for image upload\".to_string(),\n    ))?;\n  let file_id = format!(\"{}.{}\", Uuid::new_v4(), extension);\n\n  let object_key = user_image_asset_object_key(person_id, &file_id);\n  client\n    .put_blob_with_content_type(\n      &object_key,\n      ByteStream::from(image_content.data.to_vec()),\n      &content_type,\n    )\n    .await?;\n  Ok(file_id.to_string())\n}\n\npub async fn get_user_image_asset(\n  client: AwsS3BucketClientImpl,\n  person_id: &Uuid,\n  file_id: String,\n) -> Result<UserImageAssetContent, AppError> {\n  let object_key = user_image_asset_object_key(person_id, &file_id);\n  let resp = client.get_blob(&object_key).await?;\n  let content_type = resp.content_type().ok_or(AppError::InvalidContentType(\n    \"Missing content type for avatar\".to_string(),\n  ))?;\n  Ok(UserImageAssetContent {\n    data: resp.to_blob(),\n    content_type: content_type.to_string(),\n  })\n}\n"
  },
  {
    "path": "src/biz/user/mod.rs",
    "content": "pub mod image_asset;\npub mod user_delete;\npub mod user_info;\npub mod user_init;\npub mod user_verify;\n"
  },
  {
    "path": "src/biz/user/user_delete.rs",
    "content": "use crate::biz::authentication::jwt::Authorization;\nuse crate::state::GoTrueAdmin;\nuse crate::{biz::workspace::ops::delete_workspace_for_user, config::config::AppleOAuthSetting};\nuse app_error::ErrorCode;\nuse database::file::s3_client_impl::S3BucketStorage;\nuse database::workspace::{insert_workspace_ids_to_deleted_table, select_user_owned_workspaces_id};\nuse gotrue::params::AdminDeleteUserParams;\nuse redis::aio::ConnectionManager;\nuse secrecy::{ExposeSecret, Secret};\nuse shared_entity::response::AppResponseError;\nuse std::sync::Arc;\nuse tracing::info;\nuse uuid::Uuid;\n\n#[allow(clippy::too_many_arguments)]\npub async fn delete_user(\n  pg_pool: &sqlx::PgPool,\n  connection_manager: &ConnectionManager,\n  bucket_storage: &Arc<S3BucketStorage>,\n  gotrue_client: &gotrue::api::Client,\n  gotrue_admin: &GoTrueAdmin,\n  apple_oauth: &AppleOAuthSetting,\n  auth: Authorization,\n  user_uuid: Uuid,\n  provider_access_token: Option<String>,\n  provider_refresh_token: Option<String>,\n) -> Result<(), AppResponseError> {\n  if is_apple_user(&auth) {\n    if let Err(err) = revoke_apple_user(\n      &apple_oauth.client_id,\n      &apple_oauth.client_secret,\n      provider_access_token,\n      provider_refresh_token,\n    )\n    .await\n    {\n      tracing::warn!(\"revoke apple user failed: {:?}\", err);\n    };\n  }\n\n  info!(\"admin deleting user: {:?}\", user_uuid);\n  let admin_token = gotrue_admin.token().await?;\n  gotrue_client\n    .admin_delete_user(\n      &admin_token,\n      &user_uuid.to_string(),\n      &AdminDeleteUserParams {\n        should_soft_delete: false,\n      },\n    )\n    .await\n    .map_err(AppResponseError::from)?;\n\n  // spawn tasks to delete all workspaces owned by the user\n  let workspace_ids = select_user_owned_workspaces_id(pg_pool, &user_uuid).await?;\n\n  info!(\n    \"saving workspaces: {:?} to deleted workspace table\",\n    workspace_ids\n  );\n  insert_workspace_ids_to_deleted_table(pg_pool, workspace_ids.clone()).await?;\n\n  let mut tasks = vec![];\n  for workspace_id in workspace_ids {\n    let cloned_pg_pool = pg_pool.clone();\n    let connection_manager = connection_manager.clone();\n    tasks.push(tokio::spawn(delete_workspace_for_user(\n      cloned_pg_pool,\n      connection_manager,\n      workspace_id,\n      bucket_storage.clone(),\n    )));\n  }\n  for task in tasks {\n    task.await??;\n  }\n\n  Ok(())\n}\n\nasync fn revoke_apple_user(\n  client_id: &str,\n  client_secret: &Secret<String>,\n  apple_access_token: Option<String>,\n  apple_refresh_token: Option<String>,\n) -> Result<(), AppResponseError> {\n  let (type_type_hint, token) = match apple_access_token {\n    Some(access_token) => (\"access_token\", access_token),\n    None => match apple_refresh_token {\n      Some(refresh_token) => (\"refresh_token\", refresh_token),\n      None => {\n        return Err(AppResponseError::new(\n          ErrorCode::InvalidRequest,\n          \"apple email deletion must provide access_token or refresh_token\",\n        ))\n      },\n    },\n  };\n\n  if let Err(err) = revoke_apple_token_http_call(\n    client_id,\n    client_secret.expose_secret(),\n    &token,\n    type_type_hint,\n  )\n  .await\n  {\n    tracing::warn!(\"revoke apple token failed: {:?}\", err);\n  };\n  Ok(())\n}\n\nfn is_apple_user(auth: &Authorization) -> bool {\n  if let Some(provider) = auth.claims.app_metadata.get(\"provider\") {\n    if provider == \"apple\" {\n      return true;\n    }\n  };\n\n  if let Some(providers) = auth.claims.app_metadata.get(\"providers\") {\n    if let Some(providers) = providers.as_array() {\n      for provider in providers {\n        if provider == \"apple\" {\n          return true;\n        }\n      }\n    }\n  }\n\n  false\n}\n\n/// Based on: https://developer.apple.com/documentation/sign_in_with_apple/revoke_tokens\nasync fn revoke_apple_token_http_call(\n  apple_client_id: &str,\n  apple_client_secret: &str,\n  apple_user_token: &str,\n  token_type_hint: &str,\n) -> Result<(), AppResponseError> {\n  let resp = reqwest::Client::new()\n    .post(\"https://appleid.apple.com/auth/revoke\")\n    .form(&[\n      (\"client_id\", apple_client_id),\n      (\"client_secret\", apple_client_secret),\n      (\"token\", apple_user_token),\n      (\"token_type_hint\", token_type_hint),\n    ])\n    .send()\n    .await?;\n\n  let status = resp.status();\n  if status.is_success() {\n    return Ok(());\n  }\n\n  let payload = resp.text().await?;\n  Err(AppResponseError::new(\n    ErrorCode::AppleRevokeTokenError,\n    format!(\n      \"calling apple revoke, code: {}, message: {}\",\n      status, payload\n    ),\n  ))\n}\n"
  },
  {
    "path": "src/biz/user/user_info.rs",
    "content": "use app_error::AppError;\nuse database::workspace::{\n  select_all_user_non_guest_workspaces, select_all_user_workspaces, select_user_profile,\n  select_workspace_with_count_and_role,\n};\nuse database_entity::dto::{AFUserProfile, AFUserWorkspaceInfo, AFWorkspace};\nuse serde_json::json;\nuse shared_entity::dto::auth_dto::UpdateUserParams;\nuse shared_entity::response::AppResponseError;\nuse sqlx::PgPool;\nuse tracing::instrument;\nuse uuid::Uuid;\n\npub async fn get_profile(pg_pool: &PgPool, uuid: &Uuid) -> anyhow::Result<AFUserProfile, AppError> {\n  let row = select_user_profile(pg_pool, uuid)\n    .await?\n    .ok_or(AppError::RecordNotFound(format!(\n      \"Can't find the user profile for user: {}\",\n      uuid\n    )))?;\n\n  let profile = AFUserProfile::try_from(row)?;\n  Ok(profile)\n}\n\n#[instrument(level = \"debug\", skip(pg_pool), err)]\npub async fn get_user_workspace_info(\n  pg_pool: &PgPool,\n  uuid: &Uuid,\n  exclude_guest: bool,\n) -> anyhow::Result<AFUserWorkspaceInfo, AppError> {\n  let row = select_user_profile(pg_pool, uuid)\n    .await?\n    .ok_or(AppError::RecordNotFound(format!(\n      \"Can't find the user profile for {}\",\n      uuid\n    )))?;\n\n  let latest_workspace_id = row.latest_workspace_id;\n\n  // Get the user profile\n  let user_profile = AFUserProfile::try_from(row)?;\n\n  // Get all workspaces that the user can access to\n  let workspaces_rows = if exclude_guest {\n    select_all_user_non_guest_workspaces(pg_pool, uuid).await?\n  } else {\n    select_all_user_workspaces(pg_pool, uuid).await?\n  };\n  let workspaces = workspaces_rows\n    .into_iter()\n    .flat_map(|row| AFWorkspace::try_from(row).ok())\n    .collect::<Vec<AFWorkspace>>();\n\n  if workspaces.is_empty() {\n    return Err(AppError::RecordNotFound(format!(\n      \"Can't find any workspace for user: {}\",\n      uuid\n    )));\n  }\n\n  // safety: safe to unwrap since workspaces is not empty\n  let first_workspace = workspaces.first().cloned().unwrap();\n\n  let visiting_workspace = match latest_workspace_id {\n    Some(workspace_id) => AFWorkspace::try_from(\n      select_workspace_with_count_and_role(pg_pool, &workspace_id, user_profile.uid).await?,\n    )?,\n    None => first_workspace,\n  };\n\n  Ok(AFUserWorkspaceInfo {\n    user_profile,\n    visiting_workspace,\n    workspaces,\n  })\n}\n\npub async fn update_user(\n  pg_pool: &PgPool,\n  user_uuid: Uuid,\n  params: UpdateUserParams,\n) -> anyhow::Result<(), AppResponseError> {\n  let metadata = params.metadata.map(|m| json!(m.into_inner()));\n  Ok(database::user::update_user(pg_pool, &user_uuid, params.name, params.email, metadata).await?)\n}\n"
  },
  {
    "path": "src/biz/user/user_init.rs",
    "content": "use app_error::AppError;\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::Collab;\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_entity::CollabType;\nuse collab_folder::{Folder, FolderData, Workspace};\nuse collab_user::core::UserAwareness;\nuse database::collab::CollabStore;\nuse database::pg_row::AFWorkspaceRow;\nuse database_entity::dto::CollabParams;\nuse sqlx::Transaction;\nuse std::sync::Arc;\nuse tracing::{error, instrument, trace};\nuse uuid::Uuid;\nuse workspace_template::{TemplateObjectId, WorkspaceTemplate, WorkspaceTemplateBuilder};\n\n/// This function generates templates for a workspace and stores them in the database.\n/// Each template is stored as an individual collaborative object.\n#[instrument(level = \"debug\", skip_all, err)]\npub async fn initialize_workspace_for_user<T>(\n  uid: i64,\n  user_uuid: &Uuid,\n  row: &AFWorkspaceRow,\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  templates: Vec<T>,\n  collab_storage: &Arc<dyn CollabStore>,\n) -> anyhow::Result<(), AppError>\nwhere\n  T: WorkspaceTemplate + Send + Sync + 'static,\n{\n  let workspace_id = row.workspace_id;\n  let templates = WorkspaceTemplateBuilder::new(uid, &workspace_id)\n    .with_templates(templates)\n    .build()\n    .await?;\n\n  let mut database_records = vec![];\n  let mut collab_params = Vec::with_capacity(templates.len());\n  for template in templates {\n    let template_id = template.template_id;\n    let (view_id, object_id) = match &template_id {\n      TemplateObjectId::Document(oid) => (oid.to_string(), oid.to_string()),\n      TemplateObjectId::Folder(oid) => (oid.to_string(), oid.to_string()),\n      TemplateObjectId::DatabaseRow(oid) => (oid.to_string(), oid.to_string()),\n      TemplateObjectId::Database {\n        object_id,\n        database_id,\n      } => (object_id.clone(), database_id.clone()),\n    };\n    let object_id = Uuid::parse_str(&object_id)?;\n    let object_type = template.collab_type;\n    let encoded_collab_v1 = template\n      .encoded_collab\n      .encode_to_bytes()\n      .map_err(|err| AppError::Internal(anyhow::Error::from(err)))?;\n    collab_params.push(CollabParams {\n      object_id,\n      encoded_collab_v1: encoded_collab_v1.into(),\n      collab_type: object_type,\n      updated_at: None,\n    });\n\n    // push the database record\n    if object_type == CollabType::Database {\n      if let TemplateObjectId::Database {\n        object_id: _,\n        database_id,\n      } = &template_id\n      {\n        database_records.push((view_id, database_id.clone()));\n      }\n    }\n  }\n\n  collab_storage\n    .batch_insert_new_collab(workspace_id, &uid, collab_params)\n    .await?;\n\n  // Create a workspace database object for given user\n  // The database_storage_id is auto-generated when the workspace is created. So, it should be available\n  if let Some(&database_storage_id) = row.database_storage_id.as_ref() {\n    create_workspace_database_collab(\n      workspace_id,\n      &uid,\n      database_storage_id,\n      collab_storage,\n      txn,\n      database_records,\n    )\n    .await?;\n\n    match create_user_awareness(&uid, user_uuid, workspace_id, collab_storage, txn).await {\n      Ok(object_id) => trace!(\"User awareness created successfully: {}\", object_id),\n      Err(err) => {\n        error!(\n          \"Failed to create user awareness for workspace: {}, {}\",\n          workspace_id, err\n        );\n      },\n    }\n  } else {\n    return Err(AppError::Internal(anyhow::anyhow!(\n      \"Workspace database object id is missing\"\n    )));\n  }\n\n  Ok(())\n}\n\npub(crate) async fn create_user_awareness(\n  uid: &i64,\n  user_uuid: &Uuid,\n  workspace_id: Uuid,\n  storage: &Arc<dyn CollabStore>,\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n) -> Result<Uuid, AppError> {\n  let object_id = user_awareness_object_id(user_uuid, &workspace_id);\n  let collab_type = CollabType::UserAwareness;\n  let options = CollabOptions::new(object_id.to_string(), default_client_id());\n  let collab = Collab::new_with_options(CollabOrigin::Empty, options)\n    .map_err(|e| AppError::Internal(e.into()))?;\n\n  // TODO(nathan): Maybe using hardcode encoded collab\n  let user_awareness = UserAwareness::create(collab, None)?;\n  let encode_collab = user_awareness\n    .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n    .map_err(|err| AppError::Internal(err.into()))?;\n  let encoded_collab_v1 = encode_collab\n    .encode_to_bytes()\n    .map_err(|err| AppError::Internal(anyhow::Error::from(err)))?;\n\n  storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      uid,\n      CollabParams {\n        object_id,\n        encoded_collab_v1: encoded_collab_v1.into(),\n        collab_type,\n        updated_at: None,\n      },\n      txn,\n      \"create user awareness\",\n    )\n    .await?;\n  Ok(object_id)\n}\n\npub(crate) async fn create_workspace_collab(\n  uid: i64,\n  workspace_id: Uuid,\n  name: &str,\n  storage: &Arc<dyn CollabStore>,\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n) -> Result<(), AppError> {\n  let workspace = Workspace::new(workspace_id.to_string(), name.to_string(), uid);\n  let folder_data = FolderData::new(uid, workspace);\n\n  let options = CollabOptions::new(workspace_id.to_string(), default_client_id());\n  let collab = Collab::new_with_options(CollabOrigin::Empty, options)\n    .map_err(|e| AppError::Internal(e.into()))?;\n  let folder = Folder::create(collab, None, folder_data);\n  let encode_collab = folder\n    .encode_collab()\n    .map_err(|err| AppError::Internal(err.into()))?;\n\n  let encoded_collab_v1 = encode_collab\n    .encode_to_bytes()\n    .map_err(|err| AppError::Internal(anyhow::Error::from(err)))?;\n\n  storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      &uid,\n      CollabParams {\n        object_id: workspace_id,\n        encoded_collab_v1: encoded_collab_v1.into(),\n        collab_type: CollabType::Folder,\n        updated_at: None,\n      },\n      txn,\n      \"create workspace collab\",\n    )\n    .await?;\n  Ok(())\n}\n\npub(crate) async fn create_workspace_database_collab(\n  workspace_id: Uuid,\n  uid: &i64,\n  object_id: Uuid,\n  storage: &Arc<dyn CollabStore>,\n  txn: &mut Transaction<'_, sqlx::Postgres>,\n  initial_database_records: Vec<(String, String)>,\n) -> Result<(), AppError> {\n  let collab_type = CollabType::WorkspaceDatabase;\n  let options = CollabOptions::new(object_id.to_string(), default_client_id());\n  let collab = Collab::new_with_options(CollabOrigin::Empty, options)\n    .map_err(|e| AppError::Internal(e.into()))?;\n  let mut workspace_database = WorkspaceDatabase::create(collab);\n  for (object_id, database_id) in initial_database_records {\n    workspace_database.add_database(&database_id, vec![object_id]);\n  }\n  let encode_collab = workspace_database\n    .encode_collab_v1()\n    .map_err(|err| AppError::Internal(err.into()))?;\n\n  let encoded_collab_v1 = encode_collab\n    .encode_to_bytes()\n    .map_err(|err| AppError::Internal(anyhow::Error::from(err)))?;\n\n  storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      uid,\n      CollabParams {\n        object_id,\n        encoded_collab_v1: encoded_collab_v1.into(),\n        collab_type,\n        updated_at: None,\n      },\n      txn,\n      \"create database collab\",\n    )\n    .await?;\n\n  Ok(())\n}\n\npub fn user_awareness_object_id(user_uuid: &Uuid, workspace_id: &Uuid) -> Uuid {\n  Uuid::new_v5(\n    user_uuid,\n    format!(\"user_awareness:{}\", workspace_id).as_bytes(),\n  )\n}\n"
  },
  {
    "path": "src/biz/user/user_verify.rs",
    "content": "use anyhow::{Context, Result};\nuse sqlx::types::uuid;\nuse std::ops::DerefMut;\nuse std::time::Instant;\nuse tracing::{event, instrument, trace};\n\nuse app_error::AppError;\nuse database::user::{create_user, is_user_exist};\nuse database::workspace::select_workspace;\nuse database_entity::dto::AFRole;\nuse workspace_template::document::getting_started::GettingStartedTemplate;\n\nuse crate::biz::user::user_init::initialize_workspace_for_user;\nuse crate::state::AppState;\n\n/// Verify the token from the gotrue server and create the user if it is a new user\n/// Return true if the user is a new user\n///\n#[instrument(skip_all, err)]\npub async fn verify_token(access_token: &str, state: &AppState) -> Result<bool, AppError> {\n  let user = state.gotrue_client.user_info(access_token).await?;\n  let user_uuid = uuid::Uuid::parse_str(&user.id)?;\n  let name = name_from_user_metadata(&user.user_metadata);\n\n  // Create new user if it doesn't exist\n  let mut txn = state\n    .pg_pool\n    .begin()\n    .await\n    .context(\"acquire transaction to verify token\")?;\n\n  let is_new = !is_user_exist(txn.deref_mut(), &user_uuid).await?;\n  if is_new {\n    let new_uid = state.id_gen.write().await.next_id();\n    event!(tracing::Level::INFO, \"create new user:{}\", new_uid);\n    let workspace_id =\n      create_user(txn.deref_mut(), new_uid, &user_uuid, &user.email, &name).await?;\n    let workspace_row = select_workspace(txn.deref_mut(), &workspace_id).await?;\n\n    // It's essential to cache the user's role because subsequent actions will rely on this cached information.\n    state\n      .workspace_access_control\n      .insert_role(&new_uid, &workspace_id, AFRole::Owner)\n      .await?;\n    // Need to commit the transaction for the record in `af_user` to be inserted\n    // so that `initialize_workspace_for_user` will be able to find the user\n    txn\n      .commit()\n      .await\n      .context(\"fail to commit transaction to verify token\")?;\n\n    // Create a workspace with the GetStarted template\n    let mut txn2 = state.pg_pool.begin().await?;\n    let start = Instant::now();\n    initialize_workspace_for_user(\n      new_uid,\n      &user_uuid,\n      &workspace_row,\n      &mut txn2,\n      vec![GettingStartedTemplate],\n      &state.collab_storage,\n    )\n    .await?;\n    txn2\n      .commit()\n      .await\n      .context(\"fail to commit transaction to initialize workspace\")?;\n    state.metrics.collab_metrics.observe_pg_tx(start.elapsed());\n  } else {\n    trace!(\"user already exists:{},{}\", user.id, user.email);\n  }\n\n  Ok(is_new)\n}\n\n// Best effort to get user's name after oauth\nfn name_from_user_metadata(value: &serde_json::Value) -> String {\n  value\n    .get(\"name\")\n    .or(value.get(\"full_name\"))\n    .or(value.get(\"nickname\"))\n    .and_then(serde_json::Value::as_str)\n    .map(str::to_string)\n    .unwrap_or_default()\n}\n"
  },
  {
    "path": "src/biz/workspace/duplicate.rs",
    "content": "use super::page_view::{update_workspace_database_data, update_workspace_folder_data};\nuse crate::biz::collab::utils::get_latest_collab;\nuse crate::state::AppState;\nuse crate::{\n  api::metrics::AppFlowyWebMetrics,\n  biz::collab::{database::PostgresDatabaseCollabService, utils::collab_from_doc_state},\n};\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_collaborate::ws2::{CollabUpdatePublisher, WorkspaceCollabInstanceCache};\nuse collab::core::collab::default_client_id;\nuse collab_database::{\n  database::{gen_database_id, gen_row_id, timestamp, Database, DatabaseContext, DatabaseData},\n  entity::{CreateDatabaseParams, CreateViewParams},\n  rows::CreateRowParams,\n  views::OrderObjectPosition,\n  workspace_database::WorkspaceDatabase,\n};\nuse collab_document::document::Document;\nuse collab_entity::{CollabType, EncodedCollab};\nuse collab_folder::{Folder, RepeatedViewIdentifier, View, ViewIdentifier};\nuse collab_rt_entity::user::RealtimeUser;\nuse database::collab::{select_workspace_database_oid, CollabStore, GetCollabOrigin};\nuse database_entity::dto::{CollabParams, QueryCollab, QueryCollabResult};\nuse itertools::Itertools;\nuse std::{\n  collections::{HashMap, HashSet},\n  sync::Arc,\n};\nuse uuid::Uuid;\nuse yrs::block::ClientID;\n\n#[allow(clippy::too_many_arguments)]\npub async fn duplicate_view_tree_and_collab(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: Uuid,\n  suffix: &str,\n) -> Result<(), AppError> {\n  let collab_storage = state.collab_storage.clone();\n  let appflowy_web_metrics = &state.metrics.appflowy_web_metrics;\n\n  let uid = user.uid;\n  let client_id = default_client_id();\n  let mut folder: Folder = state.ws_server.get_folder(workspace_id).await?;\n  let trash_sections: HashSet<String> = folder\n    .get_all_trash_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n  let views: Vec<View> = folder\n    .get_view_recursively(&view_id.to_string(), uid)\n    .into_iter()\n    .filter(|view| !trash_sections.contains(&view.id))\n    .collect();\n  let duplicate_context = duplicate_views(&views, suffix)?;\n\n  let ws_db_oid = select_workspace_database_oid(&state.pg_pool, &workspace_id)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Unable to find workspace database oid for {}: {}\",\n        workspace_id,\n        err\n      ))\n    })?;\n  let ws_db_collab = get_latest_collab(\n    &collab_storage,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n    ws_db_oid,\n    CollabType::WorkspaceDatabase,\n    client_id,\n  )\n  .await?;\n  let mut ws_db = WorkspaceDatabase::open(ws_db_collab).map_err(|err| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Failed to open workspace database body: {}\",\n      err\n    ))\n  })?;\n\n  duplicate_database(\n    appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    collab_storage.clone(),\n    workspace_id,\n    &duplicate_context,\n    &mut ws_db,\n    client_id,\n  )\n  .await?;\n\n  duplicate_document(\n    collab_storage.clone(),\n    workspace_id,\n    uid,\n    &duplicate_context,\n    client_id,\n  )\n  .await?;\n\n  let encoded_folder_update = {\n    let mut txn = folder.collab.transact_mut();\n    for view in &duplicate_context.duplicated_views {\n      folder.body.views.insert(&mut txn, view.clone(), None, uid);\n    }\n    txn.encode_update_v1()\n  };\n  update_workspace_folder_data(\n    appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    encoded_folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\nfn duplicate_database_data_with_context(\n  context: &DuplicateContext,\n  data: &DatabaseData,\n) -> CreateDatabaseParams {\n  let database_id = gen_database_id();\n  let timestamp = timestamp();\n\n  let create_row_params = data\n    .rows\n    .iter()\n    .map(|row| CreateRowParams {\n      id: gen_row_id(),\n      database_id: database_id.clone(),\n      created_at: timestamp,\n      modified_at: timestamp,\n      cells: row.cells.clone(),\n      height: row.height,\n      visibility: row.visibility,\n      row_position: OrderObjectPosition::End,\n    })\n    .collect();\n\n  let create_view_params = data\n    .views\n    .iter()\n    .map(|view| CreateViewParams {\n      database_id: database_id.clone(),\n      view_id: context\n        .view_id_mapping\n        .get(&Uuid::parse_str(&view.id).unwrap())\n        .cloned()\n        .unwrap_or_else(Uuid::new_v4)\n        .to_string(),\n      name: view.name.clone(),\n      layout: view.layout,\n      layout_settings: view.layout_settings.clone(),\n      filters: view.filters.clone(),\n      group_settings: view.group_settings.clone(),\n      sorts: view.sorts.clone(),\n      field_settings: view.field_settings.clone(),\n      created_at: timestamp,\n      modified_at: timestamp,\n      ..Default::default()\n    })\n    .collect();\n\n  CreateDatabaseParams {\n    database_id,\n    rows: create_row_params,\n    fields: data.fields.clone(),\n    views: create_view_params,\n  }\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn duplicate_database(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  collab_storage: Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  duplicate_context: &DuplicateContext,\n  workspace_database: &mut WorkspaceDatabase,\n  client_id: ClientID,\n) -> Result<(), AppError> {\n  let uid = user.uid;\n  let collab_service = Arc::new(PostgresDatabaseCollabService::new(\n    workspace_id,\n    collab_storage.clone(),\n    client_id,\n  ));\n  let mut database_id_list: HashSet<String> = HashSet::new();\n\n  for database_view_id in &duplicate_context.database_view_ids {\n    let database_id = workspace_database\n      .get_database_meta_with_view_id(&database_view_id.to_string())\n      .ok_or_else(|| {\n        AppError::Internal(anyhow!(\"Database view id {} not found\", database_view_id))\n      })?\n      .database_id\n      .clone();\n    database_id_list.insert(database_id);\n  }\n\n  let database_context = DatabaseContext {\n    database_collab_service: collab_service.clone(),\n    notifier: Default::default(),\n    database_row_collab_service: collab_service,\n  };\n\n  for database_id in &database_id_list {\n    let database = Database::open(database_id, database_context.clone())\n      .await\n      .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to open database: {}\", err)))?;\n    let database_data = database.get_database_data(20, true).await;\n    let params = duplicate_database_data_with_context(duplicate_context, &database_data);\n    let duplicated_database = Database::create_with_view(params, database_context.clone())\n      .await\n      .map_err(|err| {\n        AppError::Internal(anyhow::anyhow!(\"Failed to duplicate database: {}\", err))\n      })?;\n    let duplicated_view_ids = duplicated_database\n      .get_all_database_views_meta()\n      .iter()\n      .map(|meta| meta.id.clone())\n      .collect_vec();\n    let encoded_database = duplicated_database\n      .encode_database_collabs()\n      .await\n      .map_err(|err| {\n        AppError::Internal(anyhow::anyhow!(\n          \"Failed to encode database collabs: {}\",\n          err\n        ))\n      })?;\n    let mut collab_params_list = vec![];\n    let database_id = Uuid::parse_str(&duplicated_database.get_database_id())?;\n    collab_params_list.push(CollabParams {\n      object_id: database_id,\n      encoded_collab_v1: encoded_database\n        .encoded_database_collab\n        .encoded_collab\n        .encode_to_bytes()?\n        .into(),\n      collab_type: CollabType::Database,\n      updated_at: None,\n    });\n    for row in encoded_database.encoded_row_collabs {\n      collab_params_list.push(CollabParams {\n        object_id: row.object_id,\n        encoded_collab_v1: row.encoded_collab.encode_to_bytes()?.into(),\n        collab_type: CollabType::DatabaseRow,\n        updated_at: None,\n      });\n    }\n    collab_storage\n      .batch_insert_new_collab(workspace_id, &uid, collab_params_list)\n      .await?;\n    let encoded_update = {\n      let mut txn = workspace_database.collab.transact_mut();\n      workspace_database.body.add_database(\n        &mut txn,\n        duplicated_database.object_id(),\n        duplicated_view_ids,\n      );\n      txn.encode_update_v1()\n    };\n    let workspace_database_id = Uuid::parse_str(workspace_database.collab.object_id())?;\n    update_workspace_database_data(\n      appflowy_web_metrics,\n      update_publisher,\n      user.clone(),\n      workspace_id,\n      workspace_database_id,\n      encoded_update,\n    )\n    .await?;\n  }\n  Ok(())\n}\n\nasync fn duplicate_document(\n  collab_storage: Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  uid: i64,\n  duplicate_context: &DuplicateContext,\n  client_id: ClientID,\n) -> Result<(), AppError> {\n  let queries = duplicate_context\n    .document_view_ids\n    .iter()\n    .map(|id| QueryCollab {\n      object_id: *id,\n      collab_type: CollabType::Document,\n    })\n    .collect();\n  let query_results = collab_storage\n    .batch_get_collab(&uid, workspace_id, queries)\n    .await;\n  let mut collab_params_list = vec![];\n  for (collab_id, query_result) in query_results {\n    match query_result {\n      QueryCollabResult::Success { encode_collab_v1 } => {\n        let encoded_collab = EncodedCollab::decode_from_bytes(&encode_collab_v1)\n          .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to decode collab: {}\", err)))?;\n        let new_collab_id = duplicate_context\n          .view_id_mapping\n          .get(&collab_id)\n          .ok_or_else(|| {\n            AppError::Internal(anyhow::anyhow!(\n              \"Failed to find new collab id for {}\",\n              collab_id\n            ))\n          })?;\n        let new_collab_param =\n          duplicate_document_encoded_collab(&collab_id, *new_collab_id, encoded_collab, client_id)?;\n        collab_params_list.push(new_collab_param);\n      },\n      QueryCollabResult::Failed { error: _ } => {\n        tracing::warn!(\"Failed to read collab {} during duplication\", collab_id);\n      },\n    }\n  }\n  collab_storage\n    .batch_insert_new_collab(workspace_id, &uid, collab_params_list)\n    .await?;\n  Ok(())\n}\n\nstruct DuplicateContext {\n  view_id_mapping: HashMap<Uuid, Uuid>,\n  duplicated_views: Vec<View>,\n  database_view_ids: HashSet<Uuid>,\n  document_view_ids: HashSet<Uuid>,\n}\n\nfn duplicate_views(views: &[View], suffix: &str) -> Result<DuplicateContext, AppError> {\n  let root_parent_id = views\n    .first()\n    .ok_or(AppError::Internal(anyhow!(\n      \"No views available for duplication\"\n    )))?\n    .parent_view_id\n    .clone();\n  let mut view_id_mapping = HashMap::new();\n  let mut duplicated_views = vec![];\n  let mut database_view_ids = HashSet::new();\n  let mut document_view_ids = HashSet::new();\n  for view in views {\n    let view_id = Uuid::parse_str(&view.id)?;\n    let duplicated_view_id = Uuid::new_v4();\n    view_id_mapping.insert(view_id, duplicated_view_id);\n  }\n  for (index, view) in views.iter().enumerate() {\n    let view_id = Uuid::parse_str(&view.id)?;\n    let orig_parent_view_id = Uuid::parse_str(&view.parent_view_id)?;\n    let duplicated_parent_view_id = if view.parent_view_id == root_parent_id {\n      orig_parent_view_id\n    } else {\n      view_id_mapping\n        .get(&orig_parent_view_id)\n        .cloned()\n        .ok_or(AppError::Internal(anyhow::anyhow!(\n          \"Failed to find duplicated parent view id {}\",\n          view.parent_view_id\n        )))?\n    };\n    let mut duplicated_view = view.clone();\n    let mut duplicated_children = vec![];\n    for child in view.children.items.iter() {\n      let child_id = Uuid::parse_str(&child.id)?;\n      let new_view_id = view_id_mapping.get(&child_id).cloned();\n      if let Some(view_id) = new_view_id {\n        duplicated_children.push(ViewIdentifier {\n          id: view_id.to_string(),\n        });\n      }\n    }\n    duplicated_view.id = view_id_mapping\n      .get(&view_id)\n      .cloned()\n      .ok_or(AppError::Internal(anyhow::anyhow!(\n        \"Failed to find duplicated view id {}\",\n        view.id\n      )))?\n      .to_string();\n    duplicated_view.parent_view_id = duplicated_parent_view_id.to_string();\n    if index == 0 {\n      duplicated_view.name = format!(\"{}{}\", duplicated_view.name, suffix);\n    }\n    duplicated_view.created_at = timestamp();\n    duplicated_view.is_favorite = false;\n    duplicated_view.last_edited_time = 0;\n    duplicated_view.children = RepeatedViewIdentifier {\n      items: duplicated_children,\n    };\n\n    duplicated_views.push(duplicated_view);\n    match &view.layout {\n      layout if layout.is_document() => {\n        document_view_ids.insert(view_id);\n      },\n      layout if layout.is_database() => {\n        database_view_ids.insert(view_id);\n      },\n      _ => (),\n    }\n  }\n  Ok(DuplicateContext {\n    view_id_mapping,\n    duplicated_views,\n    database_view_ids,\n    document_view_ids,\n  })\n}\n\nfn duplicate_document_encoded_collab(\n  orig_object_id: &Uuid,\n  new_object_id: Uuid,\n  encoded_collab: EncodedCollab,\n  client_id: ClientID,\n) -> Result<CollabParams, AppError> {\n  let collab = collab_from_doc_state(encoded_collab.doc_state.to_vec(), orig_object_id, client_id)?;\n  let document = Document::open(collab).unwrap();\n  let data = document.get_document_data().unwrap();\n  let duplicated_document = Document::create(&new_object_id.to_string(), data, client_id)\n    .map_err(|err| AppError::Internal(anyhow::anyhow!(\"Failed to create document: {}\", err)))?;\n  let encoded_collab: EncodedCollab = duplicated_document\n    .encode_collab_v1(|c| CollabType::Document.validate_require_data(c))\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\"Failed to encode document collab: {}\", err))\n    })?;\n  Ok(CollabParams {\n    object_id: new_object_id,\n    encoded_collab_v1: encoded_collab.encode_to_bytes()?.into(),\n    collab_type: CollabType::Document,\n    updated_at: None,\n  })\n}\n"
  },
  {
    "path": "src/biz/workspace/invite.rs",
    "content": "use app_error::AppError;\nuse database::workspace::{\n  delete_all_invite_code_for_workspace, insert_workspace_invite_code, select_invitation_code_info,\n  select_invite_code_for_workspace_id, select_invited_workspace_id, upsert_workspace_member_uid,\n};\nuse rand::{distributions::Alphanumeric, Rng};\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse database_entity::dto::{AFRole, InvitationCodeInfo, WorkspaceInviteToken};\n\nconst INVITE_LINK_CODE_LENGTH: usize = 16;\n\npub async fn generate_workspace_invite_token(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  validity_period_hours: Option<i64>,\n) -> Result<WorkspaceInviteToken, AppError> {\n  delete_all_invite_code_for_workspace(pg_pool, workspace_id).await?;\n  let code = generate_workspace_invite_code();\n  let expires_at = validity_period_hours.map(|v| chrono::Utc::now() + chrono::Duration::hours(v));\n  insert_workspace_invite_code(pg_pool, workspace_id, &code, expires_at.as_ref()).await?;\n\n  Ok(WorkspaceInviteToken { code: Some(code) })\n}\n\nfn generate_workspace_invite_code() -> String {\n  let rng = rand::thread_rng();\n  rng\n    .sample_iter(&Alphanumeric)\n    .take(INVITE_LINK_CODE_LENGTH)\n    .map(char::from)\n    .collect()\n}\n\npub async fn join_workspace_invite_by_code(\n  pg_pool: &PgPool,\n  invitation_code: &str,\n  uid: i64,\n) -> Result<Uuid, AppError> {\n  let invited_workspace_id = select_invited_workspace_id(pg_pool, invitation_code).await?;\n  upsert_workspace_member_uid(pg_pool, &invited_workspace_id, uid, AFRole::Member).await?;\n  Ok(invited_workspace_id)\n}\n\npub async fn delete_workspace_invite_code(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  delete_all_invite_code_for_workspace(pg_pool, workspace_id).await?;\n  Ok(())\n}\n\npub async fn get_invite_code_for_workspace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Option<String>, AppError> {\n  let code = select_invite_code_for_workspace_id(pg_pool, workspace_id).await?;\n  Ok(code)\n}\n\npub async fn get_invitation_code_info(\n  pg_pool: &PgPool,\n  invitation_code: &str,\n  uid: i64,\n) -> Result<InvitationCodeInfo, AppError> {\n  let info_list = select_invitation_code_info(pg_pool, invitation_code, uid).await?;\n  info_list\n    .into_iter()\n    .next()\n    .ok_or(AppError::InvalidInvitationCode)\n}\n"
  },
  {
    "path": "src/biz/workspace/mod.rs",
    "content": "pub mod duplicate;\npub mod invite;\npub mod ops;\npub mod page_view;\npub mod publish;\npub mod publish_dup;\npub mod quick_note;\n"
  },
  {
    "path": "src/biz/workspace/ops.rs",
    "content": "use database_entity::dto::{\n  AFWorkspaceSettingsChange, MentionablePerson, MentionablePersonWithLastMentionedTime,\n};\nuse std::collections::HashMap;\n\nuse anyhow::{anyhow, Context};\nuse redis::AsyncCommands;\nuse serde_json::json;\nuse sqlx::{types::uuid, PgPool};\nuse std::ops::DerefMut;\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tracing::instrument;\nuse uuid::Uuid;\n\nuse access_control::workspace::WorkspaceAccessControl;\nuse app_error::{AppError, ErrorCode};\nuse appflowy_collaborate::CollabMetrics;\nuse collab_stream::model::UpdateStreamMessage;\nuse database::collab::CollabStore;\nuse database::file::s3_client_impl::S3BucketStorage;\nuse database::pg_row::AFWorkspaceMemberRow;\nuse database::user::select_uid_from_email;\nuse database::workspace::*;\nuse database_entity::dto::{\n  AFRole, AFWorkspace, AFWorkspaceInvitation, AFWorkspaceInvitationStatus, AFWorkspaceSettings,\n  GlobalComment, Reaction, WorkspaceMemberProfile, WorkspaceUsage,\n};\n\nuse crate::biz::authentication::jwt::OptionalUserUuid;\nuse crate::biz::user::user_init::{\n  create_user_awareness, create_workspace_collab, create_workspace_database_collab,\n  initialize_workspace_for_user,\n};\nuse crate::mailer::{AFCloudMailer, WorkspaceInviteMailerParam};\nuse crate::state::RedisConnectionManager;\nuse shared_entity::dto::workspace_dto::{\n  CreateWorkspaceMember, WorkspaceMemberChangeset, WorkspaceMemberInvitation,\n};\nuse shared_entity::response::AppResponseError;\nuse workspace_template::document::getting_started::GettingStartedTemplate;\n\npub(crate) const MAX_COMMENT_LENGTH: usize = 5000;\n\npub async fn delete_workspace_for_user(\n  pg_pool: PgPool,\n  mut connection_manager: RedisConnectionManager,\n  workspace_id: Uuid,\n  bucket_storage: Arc<S3BucketStorage>,\n) -> Result<(), AppResponseError> {\n  // remove files from s3\n  bucket_storage\n    .remove_dir(workspace_id.to_string().as_str())\n    .await?;\n\n  // remove from postgres\n  delete_from_workspace(&pg_pool, &workspace_id).await?;\n  let _: redis::Value = connection_manager\n    .del(UpdateStreamMessage::stream_key(&workspace_id))\n    .await\n    .map_err(|err| AppResponseError::new(ErrorCode::Internal, err.to_string()))?;\n\n  Ok(())\n}\n\n/// Create an empty workspace with default folder, workspace database and user awareness collab\n/// object.\npub async fn create_empty_workspace(\n  pg_pool: &PgPool,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_metrics: &CollabMetrics,\n  user_uuid: &Uuid,\n  user_uid: i64,\n  workspace_name: &str,\n) -> Result<AFWorkspace, AppResponseError> {\n  let new_workspace_row =\n    insert_user_workspace(pg_pool, user_uuid, workspace_name, \"\", false).await?;\n  workspace_access_control\n    .insert_role(&user_uid, &new_workspace_row.workspace_id, AFRole::Owner)\n    .await?;\n  let workspace_id = new_workspace_row.workspace_id;\n\n  // create CollabType::Folder\n  let mut txn = pg_pool.begin().await?;\n  let start = Instant::now();\n  create_workspace_collab(\n    user_uid,\n    workspace_id,\n    workspace_name,\n    collab_storage,\n    &mut txn,\n  )\n  .await?;\n\n  // create CollabType::WorkspaceDatabase\n  if let Some(&database_storage_id) = new_workspace_row.database_storage_id.as_ref() {\n    create_workspace_database_collab(\n      workspace_id,\n      &user_uid,\n      database_storage_id,\n      collab_storage,\n      &mut txn,\n      vec![],\n    )\n    .await?;\n  }\n\n  // create CollabType::UserAwareness\n  create_user_awareness(&user_uid, user_uuid, workspace_id, collab_storage, &mut txn).await?;\n  let new_workspace = AFWorkspace::try_from(new_workspace_row)?;\n  txn.commit().await?;\n  collab_metrics.observe_pg_tx(start.elapsed());\n  Ok(new_workspace)\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_workspace_for_user(\n  pg_pool: &PgPool,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_metrics: &CollabMetrics,\n  user_uuid: &Uuid,\n  user_uid: i64,\n  workspace_name: &str,\n  workspace_icon: &str,\n) -> Result<AFWorkspace, AppResponseError> {\n  let new_workspace_row =\n    insert_user_workspace(pg_pool, user_uuid, workspace_name, workspace_icon, true).await?;\n\n  workspace_access_control\n    .insert_role(&user_uid, &new_workspace_row.workspace_id, AFRole::Owner)\n    .await?;\n\n  // add create initial collab for user\n  let mut txn = pg_pool.begin().await?;\n  let start = Instant::now();\n  initialize_workspace_for_user(\n    user_uid,\n    user_uuid,\n    &new_workspace_row,\n    &mut txn,\n    vec![GettingStartedTemplate],\n    collab_storage,\n  )\n  .await?;\n  txn.commit().await?;\n  collab_metrics.observe_pg_tx(start.elapsed());\n\n  let new_workspace = AFWorkspace::try_from(new_workspace_row)?;\n  Ok(new_workspace)\n}\n\npub async fn patch_workspace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  workspace_name: Option<&str>,\n  workspace_icon: Option<&str>,\n) -> Result<(), AppResponseError> {\n  let mut tx = pg_pool.begin().await?;\n  if let Some(workspace_name) = workspace_name {\n    rename_workspace(&mut tx, workspace_id, workspace_name).await?;\n  }\n  if let Some(workspace_icon) = workspace_icon {\n    change_workspace_icon(&mut tx, workspace_id, workspace_icon).await?;\n  }\n  tx.commit().await?;\n  Ok(())\n}\n\npub async fn get_comments_on_published_view(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n  optional_user_uuid: &OptionalUserUuid,\n) -> Result<Vec<GlobalComment>, AppError> {\n  let page_owner_uuid = select_owner_of_published_collab(pg_pool, view_id).await?;\n  let comments = select_comments_for_published_view_ordered_by_recency(\n    pg_pool,\n    view_id,\n    &optional_user_uuid.as_uuid(),\n    &page_owner_uuid,\n  )\n  .await?;\n  Ok(comments)\n}\n\npub async fn create_comment_on_published_view(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n  reply_comment_id: &Option<Uuid>,\n  content: &str,\n  user_uuid: &Uuid,\n) -> Result<(), AppError> {\n  if content.len() > MAX_COMMENT_LENGTH {\n    return Err(AppError::StringLengthLimitReached(\n      \"comment content exceed limit\".to_string(),\n    ));\n  }\n  insert_comment_to_published_view(pg_pool, view_id, user_uuid, content, reply_comment_id).await?;\n  Ok(())\n}\n\npub async fn remove_comment_on_published_view(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n  comment_id: &Uuid,\n  user_uuid: &Uuid,\n) -> Result<(), AppError> {\n  check_if_user_is_allowed_to_delete_comment(pg_pool, user_uuid, view_id, comment_id).await?;\n  update_comment_deletion_status(pg_pool, comment_id).await?;\n  Ok(())\n}\n\npub async fn get_reactions_on_published_view(\n  pg_pool: &PgPool,\n  view_id: &Uuid,\n  comment_id: &Option<Uuid>,\n) -> Result<Vec<Reaction>, AppError> {\n  let reaction = match comment_id {\n    Some(comment_id) => {\n      select_reactions_for_comment_ordered_by_reaction_type_creation_time(pg_pool, comment_id)\n        .await?\n    },\n    None => {\n      select_reactions_for_published_view_ordered_by_reaction_type_creation_time(pg_pool, view_id)\n        .await?\n    },\n  };\n  Ok(reaction)\n}\n\npub async fn create_reaction_on_comment(\n  pg_pool: &PgPool,\n  comment_id: &Uuid,\n  view_id: &Uuid,\n  reaction_type: &str,\n  user_uuid: &Uuid,\n) -> Result<(), AppError> {\n  insert_reaction_on_comment(pg_pool, comment_id, view_id, user_uuid, reaction_type).await?;\n  Ok(())\n}\n\npub async fn remove_reaction_on_comment(\n  pg_pool: &PgPool,\n  comment_id: &Uuid,\n  reaction_type: &str,\n  user_uuid: &Uuid,\n) -> Result<(), AppError> {\n  delete_reaction_from_comment(pg_pool, comment_id, user_uuid, reaction_type).await?;\n  Ok(())\n}\n\npub async fn get_all_user_workspaces(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  include_member_count: bool,\n  include_role: bool,\n  exclude_guest: bool,\n) -> Result<Vec<AFWorkspace>, AppResponseError> {\n  let workspaces = if exclude_guest {\n    select_all_user_non_guest_workspaces(pg_pool, user_uuid).await?\n  } else {\n    select_all_user_workspaces(pg_pool, user_uuid).await?\n  };\n  let mut workspaces = workspaces\n    .into_iter()\n    .flat_map(|row| {\n      let result = AFWorkspace::try_from(row);\n      if let Err(err) = &result {\n        tracing::error!(\"Failed to convert workspace row to AFWorkspace: {:?}\", err);\n      }\n      result\n    })\n    .collect::<Vec<_>>();\n  if include_member_count {\n    let ids = workspaces\n      .iter()\n      .map(|row| row.workspace_id)\n      .collect::<Vec<_>>();\n    let mut member_count_by_workspace_id =\n      select_member_count_for_workspaces(pg_pool, &ids).await?;\n    for workspace in workspaces.iter_mut() {\n      if let Some(member_count) = member_count_by_workspace_id.remove(&workspace.workspace_id) {\n        workspace.member_count = Some(member_count);\n      }\n    }\n  }\n  if include_role {\n    let ids = workspaces\n      .iter()\n      .map(|row| row.workspace_id)\n      .collect::<Vec<_>>();\n    let mut roles_by_workspace_id = select_roles_for_workspaces(pg_pool, user_uuid, &ids).await?;\n    for workspace in workspaces.iter_mut() {\n      if let Some(role) = roles_by_workspace_id.remove(&workspace.workspace_id) {\n        workspace.role = Some(role.clone());\n      }\n    }\n  }\n\n  Ok(workspaces)\n}\n\n/// Returns the workspace with the given workspace_id and update the updated_at field of the\n/// workspace.\npub async fn open_workspace(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  user_uid: i64,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspace, AppResponseError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to open workspace\")?;\n  let row = select_workspace_with_count_and_role(txn.deref_mut(), workspace_id, user_uid).await?;\n  update_updated_at_of_workspace(txn.deref_mut(), user_uuid, workspace_id).await?;\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to open workspace\")?;\n  let workspace = AFWorkspace::try_from(row)?;\n\n  Ok(workspace)\n}\n\npub async fn accept_workspace_invite(\n  pg_pool: &PgPool,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  user_uid: i64,\n  user_uuid: &Uuid,\n  invite_id: &Uuid,\n) -> Result<(), AppError> {\n  let mut txn = pg_pool.begin().await?;\n  let inv = get_invitation_by_id(&mut txn, invite_id).await?;\n  if let Some(invitee_uid) = inv.invitee_uid {\n    if invitee_uid != user_uid {\n      return Err(AppError::NotInviteeOfWorkspaceInvitation(format!(\n        \"User with uid {} is not the invitee for invite_id {}\",\n        user_uid, invite_id\n      )));\n    }\n  }\n  update_workspace_invitation_set_status_accepted(&mut txn, user_uuid, invite_id).await?;\n  let invited_uid = inv\n    .invitee_uid\n    .ok_or_else(|| AppError::Internal(anyhow::anyhow!(\"Invitee uid is missing for {:?}\", inv)))?;\n  workspace_access_control\n    .insert_role(&invited_uid, &inv.workspace_id, inv.role)\n    .await?;\n  txn.commit().await?;\n  Ok(())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\n#[allow(clippy::too_many_arguments)]\npub async fn invite_workspace_members(\n  mailer: &AFCloudMailer,\n  pg_pool: &PgPool,\n  inviter: &Uuid,\n  workspace_id: &Uuid,\n  invitations: Vec<WorkspaceMemberInvitation>,\n  appflowy_web_url: &str,\n) -> Result<(), AppError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to invite workspace members\")?;\n  let inviter_name = database::user::select_name_from_uuid(pg_pool, inviter).await?;\n  let workspace_name =\n    database::workspace::select_workspace_name_from_workspace_id(pg_pool, workspace_id)\n      .await?\n      .unwrap_or_default();\n  let workspace_member_count =\n    database::workspace::select_workspace_member_count_from_workspace_id(pg_pool, workspace_id)\n      .await?\n      .unwrap_or_default();\n  let workspace_members_by_email: HashMap<_, _> =\n    database::workspace::select_workspace_member_list_exclude_guest(pg_pool, workspace_id)\n      .await?\n      .into_iter()\n      .map(|row| (row.email, row.role))\n      .collect();\n  let pending_invitations =\n    database::workspace::select_workspace_pending_invitations(pg_pool, workspace_id).await?;\n\n  // check if any of the invited users are already members of the workspace\n  for invitation in &invitations {\n    if workspace_members_by_email.contains_key(&invitation.email) {\n      return Err(AppError::InvalidRequest(format!(\n        \"User with email {} is already a member of the workspace\",\n        invitation.email\n      )));\n    }\n  }\n\n  for invitation in invitations {\n    let inviter_name = inviter_name.clone();\n    let workspace_name = workspace_name.clone();\n    let workspace_member_count = workspace_member_count.to_string();\n\n    // use default icon until we have workspace icon\n    let workspace_icon_url =\n      \"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png\".to_string();\n    let user_icon_url =\n      \"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png\"\n        .to_string();\n\n    let invite_id = match pending_invitations.get(&invitation.email) {\n      None => {\n        // user is not invited yet\n        let invite_id = uuid::Uuid::new_v4();\n        insert_workspace_invitation(\n          &mut txn,\n          &invite_id,\n          workspace_id,\n          inviter,\n          invitation.email.as_str(),\n          &invitation.role,\n        )\n        .await?;\n        invite_id\n      },\n      Some(invite_id) => {\n        tracing::warn!(\"User already invited: {}\", invitation.email);\n        *invite_id\n      },\n    };\n\n    // Generate a link such that when clicked, the user is added to the workspace.\n    let accept_url = format!(\n      \"{}/accept-invitation?invited_id={}\",\n      appflowy_web_url, invite_id\n    );\n\n    if !invitation.skip_email_send {\n      let cloned_mailer = mailer.clone();\n      let email_sending = tokio::spawn(async move {\n        cloned_mailer\n          .send_workspace_invite(\n            &invitation.email,\n            WorkspaceInviteMailerParam {\n              user_icon_url,\n              username: inviter_name,\n              workspace_name,\n              workspace_icon_url,\n              workspace_member_count,\n              accept_url,\n            },\n          )\n          .await\n      });\n      if invitation.wait_email_send {\n        email_sending.await??;\n      }\n    } else {\n      tracing::info!(\n        \"Skipping email send for workspace invite to {}\",\n        invitation.email\n      );\n    }\n  }\n\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to invite workspace members\")?;\n  Ok(())\n}\n\n#[instrument(level = \"debug\", skip_all, err)]\npub async fn list_workspace_invitations_for_user(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  status: Option<AFWorkspaceInvitationStatus>,\n) -> Result<Vec<AFWorkspaceInvitation>, AppError> {\n  let invis = select_workspace_invitations_for_user(pg_pool, user_uuid, status).await?;\n  Ok(invis)\n}\n\npub async fn get_workspace_invitations_for_user(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  invite_id: &Uuid,\n) -> Result<AFWorkspaceInvitation, AppError> {\n  let user_is_invitee =\n    select_user_is_invitee_for_workspace_invitation(pg_pool, user_uuid, invite_id).await?;\n  if !user_is_invitee {\n    return Err(AppError::NotInviteeOfWorkspaceInvitation(format!(\n      \"User with uuid {} is not the invitee for invite_id {}\",\n      user_uuid, invite_id\n    )));\n  }\n  let invitation = select_workspace_invitation_for_user(pg_pool, user_uuid, invite_id).await?;\n  Ok(invitation)\n}\n\n// use in tests only\npub async fn add_workspace_members_db_only(\n  pg_pool: &PgPool,\n  _user_uuid: &Uuid,\n  workspace_id: &Uuid,\n  members: Vec<CreateWorkspaceMember>,\n) -> Result<(), AppError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to insert workspace members\")?;\n\n  for member in members.into_iter() {\n    upsert_workspace_member_with_txn(&mut txn, workspace_id, &member.email, member.role.clone())\n      .await?;\n  }\n\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to insert workspace members\")?;\n\n  Ok(())\n}\n\npub async fn leave_workspace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  user_uuid: &Uuid,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n) -> Result<(), AppResponseError> {\n  let email = database::user::select_email_from_user_uuid(pg_pool, user_uuid).await?;\n  remove_workspace_members(pg_pool, workspace_id, &[email], workspace_access_control).await\n}\n\npub async fn remove_workspace_members(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  member_emails: &[String],\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n) -> Result<(), AppResponseError> {\n  let mut txn = pg_pool\n    .begin()\n    .await\n    .context(\"Begin transaction to delete workspace members\")?;\n\n  for email in member_emails {\n    if let Ok(uid) = select_uid_from_email(txn.deref_mut(), email)\n      .await\n      .map_err(AppResponseError::from)\n    {\n      delete_workspace_members(&mut txn, workspace_id, email.as_str()).await?;\n      workspace_access_control\n        .remove_user_from_workspace(&uid, workspace_id)\n        .await?;\n\n      // TODO: Add permission cache invalidation for removed user\n      // if let Some(realtime_server) = get_realtime_server_handle() {\n      //   realtime_server.send_to_workspace(\n      //     *workspace_id,\n      //     InvalidateUserPermissions { uid }\n      //   ).await;\n      // }\n    }\n  }\n\n  txn\n    .commit()\n    .await\n    .context(\"Commit transaction to delete workspace members\")?;\n  Ok(())\n}\n\npub async fn get_workspace_members_exclude_guest(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Vec<AFWorkspaceMemberRow>, AppError> {\n  select_workspace_member_list_exclude_guest(pg_pool, workspace_id).await\n}\n\npub async fn get_workspace_member_optional(\n  uid: i64,\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<Option<AFWorkspaceMemberRow>, AppError> {\n  let member = select_workspace_member(pg_pool, uid, workspace_id).await?;\n  Ok(member)\n}\n\npub async fn get_workspace_member(\n  uid: i64,\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspaceMemberRow, AppError> {\n  let member = select_workspace_member(pg_pool, uid, workspace_id)\n    .await?\n    .ok_or(AppError::RecordNotFound(\"user does not exists\".to_string()))?;\n  Ok(member)\n}\n\npub async fn get_workspace_owner(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspaceMemberRow, AppResponseError> {\n  Ok(select_workspace_owner(pg_pool, workspace_id).await?)\n}\n\npub async fn get_workspace_member_by_uuid(\n  member_uuid: Uuid,\n  pg_pool: &PgPool,\n  workspace_id: Uuid,\n) -> Result<AFWorkspaceMemberRow, AppResponseError> {\n  Ok(select_workspace_member_by_uuid(pg_pool, member_uuid, workspace_id).await?)\n}\n\npub async fn update_workspace_member(\n  uid: &i64,\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  changeset: &WorkspaceMemberChangeset,\n  workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n) -> Result<(), AppError> {\n  if let Some(role) = &changeset.role {\n    upsert_workspace_member(pg_pool, workspace_id, &changeset.email, role.clone()).await?;\n    workspace_access_control\n      .insert_role(uid, workspace_id, role.clone())\n      .await?;\n  }\n\n  Ok(())\n}\n\npub async fn get_workspace_document_total_bytes(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<WorkspaceUsage, AppError> {\n  let byte_count = select_workspace_total_collab_bytes(pg_pool, workspace_id).await?;\n  Ok(WorkspaceUsage {\n    total_document_size: byte_count,\n  })\n}\n\npub async fn get_workspace_settings(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<AFWorkspaceSettings, AppResponseError> {\n  let settings = select_workspace_settings(pg_pool, workspace_id).await?;\n  Ok(settings.unwrap_or_default())\n}\n\npub async fn update_workspace_settings(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  change: AFWorkspaceSettingsChange,\n) -> Result<AFWorkspaceSettings, AppResponseError> {\n  let mut tx = pg_pool.begin().await?;\n  let mut setting = select_workspace_settings(tx.deref_mut(), workspace_id)\n    .await?\n    .unwrap_or_default();\n  if let Some(disable_search_indexing) = change.disable_search_indexing {\n    setting.disable_search_indexing = disable_search_indexing;\n  }\n\n  if let Some(ai_model) = change.ai_model {\n    setting.ai_model = ai_model;\n  }\n\n  // Update the workspace settings in the database\n  upsert_workspace_settings(&mut tx, workspace_id, &setting).await?;\n  tx.commit().await?;\n  Ok(setting)\n}\n\nasync fn check_if_user_is_allowed_to_delete_comment(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  view_id: &Uuid,\n  comment_id: &Uuid,\n) -> Result<(), AppError> {\n  let is_allowed =\n    select_user_is_allowed_to_delete_comment(pg_pool, user_uuid, view_id, comment_id).await?;\n  if !is_allowed {\n    return Err(AppError::UserUnAuthorized(\n      \"User is not allowed to delete this comment\".to_string(),\n    ));\n  }\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_upload_task(\n  uid: i64,\n  task_id: Uuid,\n  task: serde_json::Value,\n  host: &str,\n  workspace_id: &str,\n  file_size: usize,\n  presigned_url: Option<String>,\n  redis_client: &RedisConnectionManager,\n  pg_pool: &PgPool,\n) -> Result<(), AppError> {\n  // Insert the task into the database\n  insert_import_task(\n    uid,\n    task_id,\n    file_size as i64,\n    workspace_id.to_string(),\n    uid,\n    Some(json!({\"host\": host})),\n    presigned_url,\n    pg_pool,\n  )\n  .await?;\n\n  let _: () = redis_client\n    .clone()\n    .xadd(\"import_task_stream\", \"*\", &[(\"task\", task.to_string())])\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to push task to Redis stream: {}\", err)))?;\n\n  Ok(())\n}\n\npub async fn num_pending_task(uid: i64, pg_pool: &PgPool) -> Result<i64, AppError> {\n  // Query to check for pending tasks for the given user ID\n  let pending = ImportTaskState::Pending as i16;\n  let query = \"\n        SELECT COUNT(*)\n        FROM af_import_task\n        WHERE uid = $1 AND status = $2\n    \";\n\n  // Execute the query and fetch the count\n  let (count,): (i64,) = sqlx::query_as(query)\n    .bind(uid)\n    .bind(pending)\n    .fetch_one(pg_pool)\n    .await\n    .map_err(|e| AppError::Internal(anyhow::anyhow!(\"Failed to query pending tasks: {:?}\", e)))?;\n\n  Ok(count)\n}\n\npub async fn list_workspace_mentionable_persons(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  _uid: i64,\n  _user_uuid: &Uuid,\n) -> Result<Vec<MentionablePerson>, AppError> {\n  let mentionable_workspace_members_or_guests =\n    select_workspace_mentionable_members_or_guests(pg_pool, workspace_id).await?;\n  // TODO: if user is guest, we should not return the contact list\n  // let is_guest = get_workspace_member(uid, pg_pool, workspace_id).await?.role == AFRole::Guest;\n  let mut persons = vec![];\n  persons.extend(\n    mentionable_workspace_members_or_guests\n      .into_iter()\n      .map(|row| row.into()),\n  );\n  Ok(persons)\n}\n\npub async fn list_workspace_mentionable_persons_with_last_mentioned_time(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  _uid: i64,\n  _user_uuid: &Uuid,\n) -> Result<Vec<MentionablePersonWithLastMentionedTime>, AppError> {\n  let mentionable_workspace_members_or_guests =\n    select_workspace_mentionable_members_or_guests_with_last_mentioned_time(pg_pool, workspace_id)\n      .await?;\n  // TODO: if user is guest, we should not return the contact list\n  // let is_guest = get_workspace_member(uid, pg_pool, workspace_id).await?.role == AFRole::Guest;\n  let mut persons = vec![];\n  persons.extend(\n    mentionable_workspace_members_or_guests\n      .into_iter()\n      .map(|row| row.into()),\n  );\n  Ok(persons)\n}\n\npub async fn get_workspace_mentionable_person(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  person_id: &Uuid,\n) -> Result<MentionablePerson, AppError> {\n  let mentionable_workspace_members_or_guests =\n    select_workspace_mentionable_member_or_guest_by_uuid(pg_pool, workspace_id, person_id).await?;\n  if let Some(mentionable) = mentionable_workspace_members_or_guests {\n    Ok(mentionable.into())\n  } else {\n    Err(AppError::RecordNotFound(format!(\n      \"person with ID {} is neither a workspace member nor contact\",\n      person_id\n    )))\n  }\n}\n\npub async fn update_workspace_member_profile(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  uid: i64,\n  updated_profile: &WorkspaceMemberProfile,\n) -> Result<(), AppError> {\n  upsert_workspace_member_profile(pg_pool, workspace_id, uid, updated_profile).await?;\n  Ok(())\n}\n"
  },
  {
    "path": "src/biz/workspace/page_view.rs",
    "content": "use super::publish::PublishedCollabStore;\nuse crate::api::metrics::AppFlowyWebMetrics;\nuse crate::biz::chat::ops::create_chat;\nuse crate::biz::collab::database::{\n  resolve_dependencies_when_create_database_linked_view, LinkedViewDependencies,\n};\nuse crate::biz::collab::folder_view::{\n  check_if_space_is_private, check_if_view_is_space, get_prev_view_id,\n  get_space_view_for_current_view, parse_extra_field_as_json, to_dto_view_icon, to_dto_view_layout,\n  to_folder_view_icon, to_folder_view_layout, to_space_permission,\n};\nuse crate::biz::collab::ops::get_latest_workspace_database;\nuse crate::biz::collab::utils::{\n  batch_get_latest_collab_encoded, collab_to_doc_state, get_latest_collab,\n  get_latest_collab_database_body, DUMMY_UID,\n};\nuse crate::state::AppState;\nuse anyhow::anyhow;\nuse app_error::AppError;\nuse appflowy_collaborate::ws2::{CollabUpdatePublisher, WorkspaceCollabInstanceCache};\nuse chrono::DateTime;\nuse collab::core::collab::{default_client_id, Collab, CollabOptions};\nuse collab::core::origin::CollabClient;\nuse collab_database::database::{\n  gen_database_group_id, gen_database_id, gen_field_id, gen_row_id, Database, DatabaseContext,\n};\nuse collab_database::database_trait::NoPersistenceDatabaseCollabService;\nuse collab_database::entity::{CreateDatabaseParams, CreateViewParams, EncodedDatabase, FieldType};\nuse collab_database::fields::select_type_option::{\n  SelectOption, SelectOptionColor, SelectOptionIds, SingleSelectTypeOption,\n};\nuse collab_database::fields::{\n  default_field_settings_by_layout_map, default_field_settings_for_fields, Field,\n};\nuse collab_database::rows::{new_cell_builder, CreateRowParams};\nuse collab_database::template::entity::CELL_DATA;\nuse collab_database::views::{\n  BoardLayoutSetting, CalendarLayoutSetting, DatabaseLayout, Group, GroupSetting, GroupSettingMap,\n  LayoutSetting, LayoutSettings,\n};\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_database::{database::DatabaseBody, rows::RowId};\nuse collab_document::document::{Document, DocumentBody};\nuse collab_document::document_data::default_document_data;\nuse collab_entity::{CollabType, EncodedCollab};\nuse collab_folder::hierarchy_builder::NestedChildViewBuilder;\nuse collab_folder::{timestamp, CollabOrigin, Folder, SectionItem, SpaceInfo, View};\nuse collab_rt_entity::user::RealtimeUser;\nuse database::collab::{\n  select_collab_meta_from_af_collab, select_workspace_database_oid, CollabStore, GetCollabOrigin,\n};\nuse database::publish::select_published_view_ids_for_workspace;\nuse database::user::{select_uuid_from_uid, select_web_user_from_uid};\nuse database::workspace::{\n  select_workspace_member_uuid_exclude_guest, select_workspace_mentionable_members_or_guests,\n  upsert_page_mention,\n};\nuse database_entity::dto::{\n  CollabParams, MentionablePerson, MentionablePersonWithAccess, PageMentionUpdate,\n  PublishCollabItem, PublishCollabMetadata, QueryCollab, QueryCollabResult,\n};\nuse fancy_regex::Regex;\nuse itertools::Itertools;\nuse rayon::iter::{IntoParallelIterator, ParallelIterator};\nuse serde_json::json;\nuse shared_entity::dto::chat_dto::CreateChatParams;\nuse shared_entity::dto::publish_dto::{PublishDatabaseData, PublishViewInfo, PublishViewMetaData};\nuse shared_entity::dto::workspace_dto::{\n  FolderView, Page, PageCollab, PageCollabData, Space, SpacePermission, ViewIcon, ViewLayout,\n};\nuse sqlx::PgPool;\nuse std::collections::{HashMap, HashSet};\nuse std::sync::{Arc, LazyLock};\nuse std::time::{Duration, Instant};\nuse tokio::time::timeout_at;\nuse tracing::instrument;\nuse uuid::Uuid;\nuse workspace_template::document::parser::{JsonToDocumentParser, SerdeBlock};\nuse yrs::block::ClientID;\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_space(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  space_permission: &SpacePermission,\n  name: &str,\n  space_icon: &str,\n  space_icon_color: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = update_space_properties(\n    view_id,\n    &mut folder,\n    space_permission,\n    name,\n    space_icon,\n    space_icon_color,\n    user.uid,\n  )\n  .await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_space(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  space_permission: &SpacePermission,\n  name: &str,\n  space_icon: &str,\n  space_color: &str,\n  view_id_override: Option<Uuid>,\n) -> Result<Space, AppError> {\n  let view_id = view_id_override.unwrap_or(Uuid::new_v4());\n  let client_id = default_client_id();\n  let default_document_collab_params =\n    prepare_default_document_collab_param(client_id, view_id).await?;\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = add_new_space_to_folder(\n    user.uid,\n    &workspace_id,\n    &view_id,\n    &mut folder,\n    space_permission,\n    name,\n    space_icon,\n    space_color,\n  )\n  .await?;\n  let mut transaction = state.pg_pool.begin().await?;\n  let start = Instant::now();\n  let action = format!(\"Create new space: {}\", view_id);\n  state\n    .collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      &user.uid,\n      default_document_collab_params,\n      &mut transaction,\n      &action,\n    )\n    .await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  transaction.commit().await?;\n  state.metrics.collab_metrics.observe_pg_tx(start.elapsed());\n  Ok(Space { view_id })\n}\n\n// Different from create page as this function does not create an associated collab\n#[allow(clippy::too_many_arguments)]\npub async fn create_folder_view(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  view_layout: ViewLayout,\n  name: Option<&str>,\n  view_id: Option<Uuid>,\n  database_id: Option<Uuid>,\n) -> Result<Page, AppError> {\n  let view_id = view_id.unwrap_or_else(Uuid::new_v4);\n  let collab_origin = GetCollabOrigin::User { uid: user.uid };\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = add_new_view_to_folder(\n    user.uid,\n    parent_view_id,\n    &view_id,\n    &mut folder,\n    name,\n    to_folder_view_layout(view_layout),\n  )\n  .await?;\n  let (workspace_database_id, workspace_database_update) = if let Some(database_id) = database_id {\n    let (workspace_database_id, mut workspace_database) = get_latest_workspace_database(\n      &state.collab_storage,\n      &state.pg_pool,\n      collab_origin,\n      workspace_id,\n    )\n    .await?;\n    let workspace_database_update = add_new_database_view_for_workspace_database(\n      &mut workspace_database,\n      &database_id.to_string(),\n      &view_id,\n    )\n    .await?;\n    (Some(workspace_database_id), Some(workspace_database_update))\n  } else {\n    (None, None)\n  };\n\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  if let (Some(workspace_database_id), Some(workspace_database_update)) =\n    (workspace_database_id, workspace_database_update)\n  {\n    update_workspace_database_data(\n      &state.metrics.appflowy_web_metrics,\n      &state.ws_server,\n      user,\n      workspace_id,\n      workspace_database_id,\n      workspace_database_update,\n    )\n    .await?;\n  }\n  Ok(Page { view_id })\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  view_layout: &ViewLayout,\n  name: Option<&str>,\n  page_data: Option<&serde_json::Value>,\n  view_id: Option<Uuid>,\n  collab_id: Option<Uuid>,\n) -> Result<Page, AppError> {\n  match view_layout {\n    ViewLayout::Document => {\n      create_document_page(\n        state,\n        user,\n        workspace_id,\n        parent_view_id,\n        name,\n        page_data,\n        view_id,\n        collab_id,\n      )\n      .await\n    },\n    //TODO: allow view id and database id to be overriden\n    ViewLayout::Grid => create_grid_page(state, user, workspace_id, parent_view_id, name).await,\n    ViewLayout::Calendar => {\n      create_calendar_page(state, user, workspace_id, parent_view_id, name).await\n    },\n    ViewLayout::Board => create_board_page(state, user, workspace_id, parent_view_id, name).await,\n    ViewLayout::Chat => create_chat_page(state, user, workspace_id, parent_view_id, name).await,\n  }\n}\n\nasync fn prepare_document_collab_param_with_initial_data(\n  client_id: ClientID,\n  page_data: serde_json::Value,\n  collab_id: Uuid,\n) -> Result<CollabParams, AppError> {\n  let params = tokio::task::spawn_blocking(move || {\n    let options = CollabOptions::new(collab_id.to_string(), client_id);\n    let collab = Collab::new_with_options(CollabOrigin::Empty, options)\n      .map_err(|e| AppError::Internal(e.into()))?;\n    let document_data = JsonToDocumentParser::json_to_document(page_data)?;\n    let document = Document::create_with_data(collab, document_data)\n      .map_err(|err| AppError::InvalidPageData(err.to_string()))?;\n    let encoded_collab_v1 = document\n      .encode_collab()\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n          \"Failed to encode document with initial data: {}\",\n          err\n        ))\n      })?\n      .encode_to_bytes()?;\n    Ok::<_, AppError>(CollabParams {\n      object_id: collab_id,\n      encoded_collab_v1: encoded_collab_v1.into(),\n      collab_type: CollabType::Document,\n      updated_at: None,\n    })\n  })\n  .await??;\n  Ok(params)\n}\n\nasync fn prepare_default_document_collab_param(\n  client_id: ClientID,\n  collab_id: Uuid,\n) -> Result<CollabParams, AppError> {\n  let params = tokio::task::spawn_blocking(move || {\n    let object_id = collab_id.to_string();\n    let document_data = default_document_data(&object_id);\n    let document = Document::create(&object_id, document_data, client_id)\n      .map_err(|err| AppError::Internal(anyhow!(\"Failed to create default document: {}\", err)))?;\n    let encoded_collab_v1 = document\n      .encode_collab()\n      .map_err(|err| AppError::Internal(anyhow!(\"Failed to encode default document: {}\", err)))?\n      .encode_to_bytes()?;\n    Ok::<_, AppError>(CollabParams {\n      object_id: collab_id,\n      encoded_collab_v1: encoded_collab_v1.into(),\n      collab_type: CollabType::Document,\n      updated_at: None,\n    })\n  })\n  .await??;\n  Ok(params)\n}\n\npub async fn create_orphaned_view(\n  uid: i64,\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  document_id: Uuid,\n) -> Result<(), AppError> {\n  let default_document_collab_params =\n    prepare_default_document_collab_param(default_client_id(), document_id).await?;\n  let mut transaction = pg_pool.begin().await?;\n  let action = format!(\"Create new orphaned view: {}\", document_id);\n  collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      &uid,\n      default_document_collab_params,\n      &mut transaction,\n      &action,\n    )\n    .await?;\n  transaction.commit().await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn prepare_new_encoded_database(\n  view_id: &Uuid,\n  database_id: &Uuid,\n  name: &str,\n  fields: Vec<Field>,\n  rows: Vec<CreateRowParams>,\n  database_layout: DatabaseLayout,\n  layout_setting: Option<LayoutSetting>,\n  group_settings: Vec<GroupSettingMap>,\n) -> Result<EncodedDatabase, AppError> {\n  let timestamp = collab_database::database::timestamp();\n  let service = Arc::new(NoPersistenceDatabaseCollabService::new(default_client_id()));\n  let context = DatabaseContext::new(service.clone(), service);\n  let field_settings = default_field_settings_for_fields(&fields, database_layout);\n  let mut layout_settings = LayoutSettings::default();\n  if let Some(layout_setting) = layout_setting {\n    layout_settings.insert(database_layout, layout_setting);\n  }\n  let params = CreateDatabaseParams {\n    database_id: database_id.to_string(),\n    fields,\n    rows,\n    views: vec![CreateViewParams {\n      database_id: database_id.to_string(),\n      view_id: view_id.to_string(),\n      name: name.to_string(),\n      layout: database_layout,\n      layout_settings,\n      filters: vec![],\n      group_settings,\n      sorts: vec![],\n      field_settings,\n      created_at: timestamp,\n      modified_at: timestamp,\n      ..Default::default()\n    }],\n  };\n  let database = Database::create_with_view(params, context)\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to create database with view: {}\", err)))?;\n  database\n    .encode_database_collabs()\n    .await\n    .map_err(|err| AppError::Internal(anyhow!(\"Failed to encode database: {}\", err)))\n}\n\nasync fn prepare_default_calendar_encoded_database(\n  view_id: &Uuid,\n  database_id: &Uuid,\n  name: &str,\n) -> Result<EncodedDatabase, AppError> {\n  let text_field = Field::from_field_type(\"Title\", FieldType::RichText, true);\n  let date_field = Field::from_field_type(\"Date\", FieldType::DateTime, false);\n  let date_field_id = date_field.id.clone();\n  let multi_select_field = Field::from_field_type(\"Tags\", FieldType::MultiSelect, false);\n  let fields = vec![text_field, date_field, multi_select_field];\n  let layout_setting = CalendarLayoutSetting::new(date_field_id);\n\n  prepare_new_encoded_database(\n    view_id,\n    database_id,\n    name,\n    fields,\n    vec![],\n    DatabaseLayout::Calendar,\n    Some(layout_setting.into()),\n    vec![],\n  )\n  .await\n}\n\nasync fn prepare_default_grid_encoded_database(\n  view_id: &Uuid,\n  database_id: &Uuid,\n  name: &str,\n) -> Result<EncodedDatabase, AppError> {\n  let text_field = Field::from_field_type(\"Name\", FieldType::RichText, true);\n  let single_select_field = Field::from_field_type(\"Type\", FieldType::SingleSelect, false);\n  let checkbox_field = Field::from_field_type(\"Done\", FieldType::Checkbox, false);\n  let fields = vec![text_field, single_select_field, checkbox_field];\n  let rows = (0..3)\n    .map(|_| CreateRowParams::new(gen_row_id(), database_id.to_string()))\n    .collect();\n\n  prepare_new_encoded_database(\n    view_id,\n    database_id,\n    name,\n    fields,\n    rows,\n    DatabaseLayout::Grid,\n    None,\n    vec![],\n  )\n  .await\n}\n\nasync fn prepare_default_board_encoded_database(\n  view_id: &Uuid,\n  database_id: &Uuid,\n  name: &str,\n) -> Result<EncodedDatabase, AppError> {\n  let card_title_field = Field::from_field_type(\"Description\", FieldType::RichText, true);\n  let text_field_id = card_title_field.id.clone();\n\n  let to_do_option = SelectOption::with_color(\"To Do\", SelectOptionColor::Purple);\n  let doing_option = SelectOption::with_color(\"Doing\", SelectOptionColor::Orange);\n  let done_option = SelectOption::with_color(\"Done\", SelectOptionColor::Yellow);\n  let default_option_id = to_do_option.id.clone();\n  let options = vec![to_do_option, doing_option, done_option];\n  let card_status_option_ids: Vec<String> =\n    options.iter().map(|option| option.id.clone()).collect();\n  let mut card_status_options = SingleSelectTypeOption::default();\n  card_status_options.options.extend(options);\n  let mut card_status_field = Field::new(\n    gen_field_id(),\n    \"Status\".to_string(),\n    FieldType::SingleSelect.into(),\n    false,\n  );\n  card_status_field.type_options.insert(\n    FieldType::SingleSelect.to_string(),\n    card_status_options.into(),\n  );\n\n  let card_status_field_id = card_status_field.id.clone();\n  let card_status_field_type = card_status_field.field_type;\n  let mut group_ids = vec![card_status_field_id.clone()];\n  group_ids.extend(card_status_option_ids);\n  let groups = group_ids.iter().map(|id| Group::new(id.clone())).collect();\n  let group_settings: Vec<GroupSettingMap> = vec![GroupSetting {\n    id: gen_database_group_id(),\n    field_id: card_status_field_id.clone(),\n    field_type: card_status_field_type,\n    groups,\n    content: Default::default(),\n  }\n  .into()];\n\n  let mut rows = vec![];\n  let card_status_select_option_ids = SelectOptionIds::from(vec![default_option_id.clone()]);\n  for i in 0..3 {\n    let card_status_cell_data = card_status_select_option_ids.to_cell(FieldType::SingleSelect);\n    let mut description_cell = new_cell_builder(FieldType::RichText);\n    let description_text = format!(\"Card {}\", i + 1);\n    description_cell.insert(CELL_DATA.into(), description_text.into());\n    let mut row = CreateRowParams::new(gen_row_id(), database_id.to_string());\n    row\n      .cells\n      .insert(card_status_field_id.clone(), card_status_cell_data);\n    row.cells.insert(text_field_id.clone(), description_cell);\n    rows.push(row);\n  }\n  let fields = vec![card_title_field, card_status_field];\n  let layout_setting = BoardLayoutSetting {\n    hide_ungrouped_column: true,\n    collapse_hidden_groups: true,\n  };\n\n  prepare_new_encoded_database(\n    view_id,\n    database_id,\n    name,\n    fields,\n    rows,\n    DatabaseLayout::Board,\n    Some(layout_setting.into()),\n    group_settings,\n  )\n  .await\n}\n\npub async fn append_block_at_the_end_of_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  serde_blocks: &[SerdeBlock],\n) -> Result<(), AppError> {\n  let oid = Uuid::parse_str(view_id)?;\n  let update = append_block_to_document_collab(\n    user.uid,\n    &state.collab_storage,\n    workspace_id,\n    oid,\n    serde_blocks,\n  )\n  .await?;\n  update_page_collab_data(state, user, workspace_id, oid, CollabType::Document, update).await\n}\n\nasync fn append_block_to_document_collab(\n  uid: i64,\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_id: Uuid,\n  oid: Uuid,\n  serde_blocks: &[SerdeBlock],\n) -> Result<Vec<u8>, AppError> {\n  let mut collab = get_latest_collab(\n    collab_storage,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n    oid,\n    CollabType::Document,\n    default_client_id(),\n  )\n  .await?;\n\n  let document_body = DocumentBody::from_collab(&collab)\n    .ok_or_else(|| AppError::Internal(anyhow::anyhow!(\"invalid document collab\")))?;\n  let document_data = {\n    let txn = collab.transact();\n    document_body\n      .get_document_data(&txn)\n      .map_err(|err| AppError::Internal(anyhow::anyhow!(err.to_string())))\n  }?;\n  let page_id = document_data.page_id.clone();\n  let page_id_children_id = document_data\n    .blocks\n    .get(&page_id)\n    .map(|block| block.children.clone());\n  let mut prev_id = page_id_children_id\n    .and_then(|children_id| document_data.meta.children_map.get(&children_id))\n    .and_then(|child_ids| child_ids.last().cloned());\n\n  let update = {\n    let mut txn = collab.transact_mut();\n    for serde_block in serde_blocks {\n      let (block_index_map, text_map) =\n        JsonToDocumentParser::generate_blocks(serde_block, None, page_id.clone());\n\n      for (block_id, block) in block_index_map.iter() {\n        document_body\n          .insert_block(&mut txn, block.clone(), prev_id.clone())\n          .map_err(|err| AppError::InvalidBlock(err.to_string()))?;\n        prev_id = Some(block_id.clone());\n      }\n\n      for (text_id, text) in text_map.iter() {\n        let delta = serde_json::from_str(text).unwrap_or_else(|_| vec![]);\n        document_body\n          .text_operation\n          .apply_delta(&mut txn, text_id, delta);\n      }\n    }\n    txn.encode_update_v1()\n  };\n  Ok(update)\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn add_new_space_to_folder(\n  uid: i64,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n  folder: &mut Folder,\n  space_permission: &SpacePermission,\n  name: &str,\n  space_icon: &str,\n  space_icon_color: &str,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let view = NestedChildViewBuilder::new(uid, workspace_id.to_string())\n      .with_view_id(view_id)\n      .with_name(name)\n      .with_extra(|builder| {\n        builder\n          .with_space_info(SpaceInfo {\n            space_icon: Some(space_icon.to_string()),\n            space_icon_color: Some(space_icon_color.to_string()),\n            space_permission: to_space_permission(space_permission),\n            ..Default::default()\n          })\n          .build()\n      })\n      .build()\n      .view;\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.insert(&mut txn, view, None, uid);\n    if *space_permission == SpacePermission::Private {\n      folder.body.views.update_view(\n        &mut txn,\n        &view_id.to_string(),\n        |update| update.set_private(true).done(),\n        uid,\n      );\n    }\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn update_space_properties(\n  view_id: &str,\n  folder: &mut Folder,\n  space_permission: &SpacePermission,\n  name: &str,\n  space_icon: &str,\n  space_icon_color: &str,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| {\n        let extra = json!({\n          \"is_space\": true,\n          \"space_permission\": to_space_permission(space_permission) as u8,\n          \"space_created_at\": timestamp(),\n          \"space_icon\": space_icon,\n          \"space_icon_color\": space_icon_color,\n        })\n        .to_string();\n        let is_private = *space_permission == SpacePermission::Private;\n        update\n          .set_name(name)\n          .set_extra(&extra)\n          .set_private(is_private)\n          .done()\n      },\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn add_new_database_view_for_workspace_database(\n  workspace_database: &mut WorkspaceDatabase,\n  database_id: &str,\n  view_id: &Uuid,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = workspace_database.collab.transact_mut();\n    workspace_database\n      .body\n      .update_database(&mut txn, database_id, |record| {\n        // Check if the view is already linked to the database.\n        if !record.linked_views.contains(&view_id.to_string()) {\n          record.linked_views.push(view_id.to_string());\n        }\n      });\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn add_new_database_to_workspace(\n  workspace_database: &mut WorkspaceDatabase,\n  database_id: &Uuid,\n  view_id: &Uuid,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_updates = {\n    let mut txn = workspace_database.collab.transact_mut();\n    workspace_database.body.add_database(\n      &mut txn,\n      &database_id.to_string(),\n      vec![view_id.to_string()],\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_updates)\n}\n\npub async fn update_current_view(\n  uid: i64,\n  view_id: &str,\n  folder: &mut Folder,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder\n      .body\n      .set_current_view(&mut txn, view_id.to_string(), uid);\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn add_new_view_to_folder(\n  uid: i64,\n  parent_view_id: &Uuid,\n  view_id: &Uuid,\n  folder: &mut Folder,\n  name: Option<&str>,\n  layout: collab_folder::ViewLayout,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let view = NestedChildViewBuilder::new(uid, parent_view_id.to_string())\n      .with_view_id(view_id)\n      .with_name(name.unwrap_or_default())\n      .with_layout(layout)\n      .build()\n      .view;\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.insert(&mut txn, view, None, uid);\n\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn update_favorite_view(\n  view_id: &str,\n  folder: &mut Folder,\n  is_favorite: bool,\n  is_pinned: bool,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let existing_extra: Option<serde_json::Value> = folder\n    .get_view(view_id, uid)\n    .ok_or_else(|| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Failed to find view with id {} in folder\",\n        view_id\n      ))\n    })?\n    .extra\n    .as_ref()\n    .map(|extra| serde_json::from_str(extra))\n    .transpose()?;\n  let extra = if let Some(mut existing_extra) = existing_extra {\n    existing_extra[\"is_pinned\"] = serde_json::Value::Bool(is_pinned);\n    existing_extra.to_string()\n  } else {\n    json!({\"is_pinned\": is_pinned}).to_string().to_string()\n  };\n\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_favorite(is_favorite).set_extra(extra).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn reorder_favorite_section(\n  view_id: &str,\n  prev_view_id: Option<&str>,\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    if let Some(op) = folder\n      .body\n      .section\n      .section_op(&txn, collab_folder::Section::Favorite, uid)\n    {\n      op.move_section_item_with_txn(&mut txn, view_id, prev_view_id);\n    };\n    txn.encode_update_v1()\n  };\n\n  Ok(encoded_update)\n}\n\nasync fn update_view_properties(\n  view_id: &str,\n  folder: &mut Folder,\n  name: &str,\n  icon: Option<&ViewIcon>,\n  is_locked: Option<bool>,\n  extra: Option<impl AsRef<str>>,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    let icon = icon.map(|icon| to_folder_view_icon(icon.clone()));\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| {\n        update\n          .set_name(name)\n          .set_icon(icon)\n          .set_extra_if_not_none(extra)\n          .set_is_locked(is_locked)\n          .done()\n      },\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn update_view_name(\n  view_id: &str,\n  folder: &mut Folder,\n  name: &str,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_name(name).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn update_view_icon(\n  view_id: &str,\n  folder: &mut Folder,\n  icon: Option<&ViewIcon>,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    let icon = icon.map(|icon| to_folder_view_icon(icon.clone()));\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_icon(icon).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn update_view_extra(\n  view_id: &str,\n  folder: &mut Folder,\n  extra: &str,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_extra(extra).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn move_view(\n  view_id: &str,\n  new_parent_view_id: &str,\n  prev_view_id: Option<String>,\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder\n      .body\n      .move_nested_view(&mut txn, view_id, new_parent_view_id, prev_view_id, uid);\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn move_view_to_trash(\n  view_id: &str,\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let mut current_view_and_descendants = folder\n    .get_views_belong_to(view_id, uid)\n    .iter()\n    .map(|v| v.id.clone())\n    .collect_vec();\n  current_view_and_descendants.push(view_id.to_string());\n\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    current_view_and_descendants.iter().for_each(|view_id| {\n      folder.body.views.update_view(\n        &mut txn,\n        view_id,\n        |update| update.set_favorite(false).done(),\n        uid,\n      );\n    });\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_trash(true).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn move_view_out_from_trash(\n  view_id: &str,\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_trash(false).done(),\n      uid,\n    );\n    txn.encode_update_v1()\n  };\n  Ok(encoded_update)\n}\n\nasync fn extend_recent_views(\n  recent_view_ids: &[String],\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let existing_recent_sections: HashSet<String> = folder\n    .get_all_recent_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n  let section_id_to_be_removed = existing_recent_sections\n    .intersection(&recent_view_ids.iter().cloned().collect())\n    .cloned()\n    .collect_vec();\n  let section_item_to_be_added = recent_view_ids\n    .iter()\n    .map(|id| SectionItem::new(id.clone()))\n    .collect_vec();\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    if let Some(op) = folder\n      .body\n      .section\n      .section_op(&txn, collab_folder::Section::Recent, uid)\n    {\n      op.delete_section_items_with_txn(&mut txn, section_id_to_be_removed);\n      op.add_sections_item(&mut txn, section_item_to_be_added);\n    };\n    txn.encode_update_v1()\n  };\n\n  Ok(encoded_update)\n}\n\nasync fn move_all_views_out_from_trash(folder: &mut Folder, uid: i64) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    if let Some(op) = folder\n      .body\n      .section\n      .section_op(&txn, collab_folder::Section::Trash, uid)\n    {\n      op.clear(&mut txn);\n    };\n    txn.encode_update_v1()\n  };\n\n  Ok(encoded_update)\n}\n\nasync fn delete_view_from_trash(\n  view_id: &str,\n  folder: &mut Folder,\n  uid: i64,\n) -> Result<Vec<u8>, AppError> {\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    folder.body.views.update_view(\n      &mut txn,\n      view_id,\n      |update| update.set_trash(false).done(),\n      uid,\n    );\n    folder.body.views.delete_views(&mut txn, vec![view_id]);\n    txn.encode_update_v1()\n  };\n\n  Ok(encoded_update)\n}\n\nasync fn delete_all_views_from_trash(folder: &mut Folder, uid: i64) -> Result<Vec<u8>, AppError> {\n  let all_trash_ids: Vec<String> = folder\n    .get_all_trash_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n\n  let encoded_update = {\n    let mut txn = folder.collab.transact_mut();\n    if let Some(op) = folder\n      .body\n      .section\n      .section_op(&txn, collab_folder::Section::Trash, uid)\n    {\n      op.clear(&mut txn);\n    };\n    folder.body.views.delete_views(&mut txn, all_trash_ids);\n    txn.encode_update_v1()\n  };\n\n  Ok(encoded_update)\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_document_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  name: Option<&str>,\n  page_data: Option<&serde_json::Value>,\n  view_id_override: Option<Uuid>,\n  collab_id_override: Option<Uuid>,\n) -> Result<Page, AppError> {\n  let client_id = default_client_id();\n  let collab_id = collab_id_override.unwrap_or(Uuid::new_v4());\n\n  let new_document_collab_params = match page_data {\n    Some(page_data) => {\n      prepare_document_collab_param_with_initial_data(client_id, page_data.clone(), collab_id).await\n    },\n    None => prepare_default_document_collab_param(client_id, collab_id).await,\n  }?;\n  let view_id = view_id_override.unwrap_or(collab_id);\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = add_new_view_to_folder(\n    user.uid,\n    parent_view_id,\n    &view_id,\n    &mut folder,\n    name,\n    collab_folder::ViewLayout::Document,\n  )\n  .await?;\n  let mut transaction = state.pg_pool.begin().await?;\n  let start = Instant::now();\n  let action = format!(\"Create new collab: {}\", view_id);\n  state\n    .collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      &user.uid,\n      new_document_collab_params,\n      &mut transaction,\n      &action,\n    )\n    .await?;\n  transaction.commit().await?;\n\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  state.metrics.collab_metrics.observe_pg_tx(start.elapsed());\n  Ok(Page { view_id })\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_grid_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  name: Option<&str>,\n) -> Result<Page, AppError> {\n  let view_id = Uuid::new_v4();\n  let database_id: Uuid = gen_database_id().parse().unwrap();\n  let default_grid_encoded_database =\n    prepare_default_grid_encoded_database(&view_id, &database_id, name.unwrap_or_default()).await?;\n  create_database_page(\n    state,\n    user,\n    workspace_id,\n    parent_view_id,\n    &view_id,\n    collab_folder::ViewLayout::Grid,\n    name,\n    &default_grid_encoded_database,\n  )\n  .await\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_board_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  name: Option<&str>,\n) -> Result<Page, AppError> {\n  let view_id = Uuid::new_v4();\n  let database_id = Uuid::new_v4();\n  let default_board_encoded_database =\n    prepare_default_board_encoded_database(&view_id, &database_id, name.unwrap_or_default())\n      .await?;\n  create_database_page(\n    state,\n    user,\n    workspace_id,\n    parent_view_id,\n    &view_id,\n    collab_folder::ViewLayout::Board,\n    name,\n    &default_board_encoded_database,\n  )\n  .await\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_calendar_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  name: Option<&str>,\n) -> Result<Page, AppError> {\n  let view_id = Uuid::new_v4();\n  let database_id = Uuid::new_v4();\n  let default_calendar_encoded_database =\n    prepare_default_calendar_encoded_database(&view_id, &database_id, name.unwrap_or_default())\n      .await?;\n  create_database_page(\n    state,\n    user,\n    workspace_id,\n    parent_view_id,\n    &view_id,\n    collab_folder::ViewLayout::Calendar,\n    name,\n    &default_calendar_encoded_database,\n  )\n  .await\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_database_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  view_id: &Uuid,\n  view_layout: collab_folder::ViewLayout,\n  name: Option<&str>,\n  encoded_database: &EncodedDatabase,\n) -> Result<Page, AppError> {\n  let collab_origin = GetCollabOrigin::User { uid: user.uid };\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = add_new_view_to_folder(\n    user.uid,\n    parent_view_id,\n    view_id,\n    &mut folder,\n    name,\n    view_layout,\n  )\n  .await?;\n  let (workspace_database_id, mut workspace_database) = get_latest_workspace_database(\n    &state.collab_storage,\n    &state.pg_pool,\n    collab_origin,\n    workspace_id,\n  )\n  .await?;\n  let database_id = encoded_database.encoded_database_collab.object_id;\n  let workspace_database_update =\n    add_new_database_to_workspace(&mut workspace_database, &database_id, view_id).await?;\n  let database_collab_params = CollabParams {\n    object_id: database_id,\n    encoded_collab_v1: encoded_database\n      .encoded_database_collab\n      .encoded_collab\n      .encode_to_bytes()?\n      .into(),\n    collab_type: CollabType::Database,\n    updated_at: None,\n  };\n  let row_collab_params_list = encoded_database\n    .encoded_row_collabs\n    .iter()\n    .flat_map(|row_collab| {\n      Some(CollabParams {\n        object_id: row_collab.object_id,\n        encoded_collab_v1: row_collab.encoded_collab.encode_to_bytes().unwrap().into(),\n        collab_type: CollabType::DatabaseRow,\n        updated_at: None,\n      })\n    })\n    .collect();\n\n  let mut transaction = state.pg_pool.begin().await?;\n  let start = Instant::now();\n  let action = format!(\"Create new database collab: {}\", database_id);\n  state\n    .collab_storage\n    .upsert_new_collab_with_transaction(\n      workspace_id,\n      &user.uid,\n      database_collab_params,\n      &mut transaction,\n      &action,\n    )\n    .await?;\n  state\n    .collab_storage\n    .batch_insert_new_collab(workspace_id, &user.uid, row_collab_params_list)\n    .await?;\n  // Commit transaction before updating folder and workspace database.\n  // the collab object is persisted even if the subsequent Redis stream updates fail.\n  transaction.commit().await?;\n\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  update_workspace_database_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    workspace_database_id,\n    workspace_database_update,\n  )\n  .await?;\n  state.metrics.collab_metrics.observe_pg_tx(start.elapsed());\n  Ok(Page { view_id: *view_id })\n}\n\nasync fn get_rag_ids(folder: &Folder, parent_view_id: &Uuid, uid: i64) -> Vec<Uuid> {\n  let parent_view_id_str = parent_view_id.to_string();\n  if let Some(view) = folder.get_view(&parent_view_id_str, uid) {\n    if view.space_info().is_some() {\n      return vec![];\n    }\n  };\n  let trash_ids: HashSet<String> = folder\n    .get_all_trash_sections(uid)\n    .iter()\n    .map(|s| s.id.clone())\n    .collect();\n  let mut rag_ids: Vec<_> = folder\n    .get_views_belong_to(&parent_view_id_str, uid)\n    .iter()\n    .filter(|v| v.layout.is_document() && !trash_ids.contains(&v.id))\n    .flat_map(|v| Uuid::parse_str(&v.id).ok())\n    .collect();\n  rag_ids.push(*parent_view_id);\n  rag_ids\n}\n\n#[allow(clippy::too_many_arguments)]\nasync fn create_chat_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  parent_view_id: &Uuid,\n  name: Option<&str>,\n) -> Result<Page, AppError> {\n  let view_id = Uuid::new_v4();\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let rag_ids = get_rag_ids(&folder, parent_view_id, user.uid).await;\n  create_chat(\n    &state.pg_pool,\n    CreateChatParams {\n      chat_id: view_id.to_string(),\n      name: name.unwrap_or_default().to_string(),\n      rag_ids,\n    },\n    &workspace_id,\n  )\n  .await?;\n  let folder_update = add_new_view_to_folder(\n    user.uid,\n    parent_view_id,\n    &view_id,\n    &mut folder,\n    name,\n    collab_folder::ViewLayout::Chat,\n  )\n  .await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(Page { view_id })\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn move_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  new_parent_view_id: &str,\n  prev_view_id: Option<String>,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = move_view(\n    view_id,\n    new_parent_view_id,\n    prev_view_id,\n    &mut folder,\n    user.uid,\n  )\n  .await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn reorder_favorite_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  prev_view_id: Option<&str>,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update =\n    reorder_favorite_section(view_id, prev_view_id, &mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn move_page_to_trash(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let trash_info = folder.get_my_trash_info(user.uid);\n  if trash_info.into_iter().any(|info| info.id == view_id) {\n    return Ok(());\n  }\n  let folder_update = move_view_to_trash(view_id, &mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn restore_page_from_trash(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = move_view_out_from_trash(view_id, &mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn add_recent_pages(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  recent_view_ids: Vec<String>,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = extend_recent_views(&recent_view_ids, &mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn restore_all_pages_from_trash(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = move_all_views_out_from_trash(&mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn delete_trash(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let update = delete_view_from_trash(view_id, &mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    update,\n  )\n  .await?;\n  Ok(())\n}\n\npub async fn delete_all_pages_from_trash(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let update = delete_all_views_from_trash(&mut folder, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    update,\n  )\n  .await?;\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn update_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  name: &str,\n  icon: Option<&ViewIcon>,\n  is_locked: Option<bool>,\n  extra: Option<impl AsRef<str>>,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update =\n    update_view_properties(view_id, &mut folder, name, icon, is_locked, extra, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\npub async fn update_page_name(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  name: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = update_view_name(view_id, &mut folder, name, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\npub async fn update_page_icon(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  icon: Option<&ViewIcon>,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = update_view_icon(view_id, &mut folder, icon, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\npub async fn update_page_extra(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  extra: &str,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = update_view_extra(view_id, &mut folder, extra, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn favorite_page(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  view_id: &str,\n  is_favorite: bool,\n  is_pinned: bool,\n) -> Result<(), AppError> {\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update =\n    update_favorite_view(view_id, &mut folder, is_favorite, is_pinned, user.uid).await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\nstatic INVALID_URL_CHARS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"[^\\w-]\").unwrap());\n\nfn replace_invalid_url_chars(input: &str) -> String {\n  INVALID_URL_CHARS.replace_all(input, \"-\").to_string()\n}\n\nfn generate_publish_name(view_id: &str, name: &str) -> String {\n  let id_len = view_id.len();\n  let name = replace_invalid_url_chars(name);\n  let name_len = name.len();\n  // The backend limits the publish name to a maximum of 50 characters.\n  // If the combined length of the ID and the name exceeds 50 characters,\n  // we will truncate the name to ensure the final result is within the limit.\n  // The name should only contain alphanumeric characters and hyphens.\n  let result = format!(\n    \"{}-{}\",\n    &name[..std::cmp::min(49 - id_len, name_len)],\n    view_id\n  );\n  result\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn publish_page(\n  state: &AppState,\n  uid: i64,\n  user_uuid: Uuid,\n  workspace_id: Uuid,\n  view_id: Uuid,\n  visible_database_view_ids: Option<Vec<Uuid>>,\n  publish_name: Option<impl ToString>,\n  comments_enabled: bool,\n  duplicate_enabled: bool,\n) -> Result<(), AppError> {\n  let folder = state.ws_server.get_folder(workspace_id).await?;\n  let view = folder\n    .get_view(&view_id.to_string(), uid)\n    .ok_or(AppError::InvalidFolderView(format!(\n      \"View {} not found\",\n      view_id\n    )))?;\n  let icon = view\n    .icon\n    .as_ref()\n    .map(|icon| to_dto_view_icon(icon.clone()));\n  let metadata = PublishViewMetaData {\n    view: PublishViewInfo {\n      view_id: view_id.to_string(),\n      name: view.name.clone(),\n      icon,\n      layout: to_dto_view_layout(&view.layout),\n      extra: view.extra.clone(),\n      created_by: view.created_by,\n      last_edited_by: view.last_edited_by,\n      last_edited_time: view.last_edited_time,\n      created_at: view.created_at,\n      child_views: None,\n    },\n    // Note: The use of child views and ancestor views are going to be deprecated in\n    // appflowy web as there is now endpoint to obtain published outline.\n    child_views: vec![],\n    ancestor_views: vec![],\n  };\n\n  let publish_data = match view.layout {\n    collab_folder::ViewLayout::Document => {\n      generate_publish_data_for_document(&state.collab_storage, uid, workspace_id, view_id).await\n    },\n    collab_folder::ViewLayout::Grid\n    | collab_folder::ViewLayout::Board\n    | collab_folder::ViewLayout::Calendar => {\n      generate_publish_data_for_database(\n        &state.pg_pool,\n        &state.collab_storage,\n        uid,\n        workspace_id,\n        view_id,\n        visible_database_view_ids,\n      )\n      .await\n    },\n    collab_folder::ViewLayout::Chat => Err(AppError::InvalidRequest(\n      \"AI Chat cannot be published\".to_string(),\n    )),\n  }?;\n  state\n    .published_collab_store\n    .publish_collabs(\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id,\n          publish_name: publish_name\n            .map(|name| name.to_string())\n            .unwrap_or_else(|| generate_publish_name(&view.id, &view.name)),\n          metadata: serde_json::value::to_value(metadata).unwrap(),\n        },\n        data: publish_data,\n        comments_enabled,\n        duplicate_enabled,\n      }],\n      &workspace_id,\n      &user_uuid,\n    )\n    .await?;\n  Ok(())\n}\n\nasync fn generate_publish_data_for_document(\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_id: Uuid,\n  view_id: Uuid,\n) -> Result<Vec<u8>, AppError> {\n  let collab = collab_storage\n    .get_full_encode_collab(\n      GetCollabOrigin::User { uid },\n      &workspace_id,\n      &view_id,\n      CollabType::Document,\n    )\n    .await\n    .map(|v| v.encoded_collab)?;\n  Ok(collab.doc_state.to_vec())\n}\n\nasync fn generate_publish_data_for_database(\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_id: Uuid,\n  view_id: Uuid,\n  visible_database_view_ids: Option<Vec<Uuid>>,\n) -> Result<Vec<u8>, AppError> {\n  let (_, ws_db) = get_latest_workspace_database(\n    collab_storage,\n    pg_pool,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n  )\n  .await?;\n  let db_oid = {\n    ws_db\n      .get_database_meta_with_view_id(&view_id.to_string())\n      .ok_or(AppError::NoRequiredData(format!(\n        \"Database view {} not found\",\n        view_id\n      )))?\n      .database_id\n  };\n  let db_oid = Uuid::parse_str(&db_oid)?;\n  let (db_collab, db_body) =\n    get_latest_collab_database_body(collab_storage, workspace_id, db_oid).await?;\n  let inline_view_id = {\n    let txn = db_collab.transact();\n    db_body.get_inline_view_id(&txn)\n  };\n  let row_ids: Vec<_> = {\n    let txn = db_collab.transact();\n    db_body\n      .views\n      .get_row_orders(&txn, &inline_view_id)\n      .iter()\n      .flat_map(|ro| Uuid::parse_str(&ro.id))\n      .collect()\n  };\n  let encoded_rows = batch_get_latest_collab_encoded(\n    collab_storage,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n    &row_ids,\n    CollabType::DatabaseRow,\n  )\n  .await?;\n  let row_data: HashMap<_, Vec<u8>> = encoded_rows\n    .into_iter()\n    .map(|(oid, encoded_collab)| (oid, encoded_collab.doc_state.to_vec()))\n    .collect();\n\n  let row_document_ids: Vec<_> = row_ids\n    .iter()\n    .filter_map(|row_id| {\n      db_body\n        .block\n        .get_row_document_id(&RowId::from(row_id.to_owned()))\n        .and_then(|doc_id| Uuid::parse_str(&doc_id).ok())\n    })\n    .collect();\n  let encoded_row_documents = batch_get_latest_collab_encoded(\n    collab_storage,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n    &row_document_ids,\n    CollabType::Document,\n  )\n  .await?;\n  let row_document_data: HashMap<_, _> = encoded_row_documents\n    .into_iter()\n    .map(|(oid, encoded_collab)| (oid, encoded_collab.doc_state.to_vec()))\n    .collect();\n\n  let data = PublishDatabaseData {\n    database_collab: collab_to_doc_state(db_collab, CollabType::Database).await?,\n    database_row_collabs: row_data,\n    database_row_document_collabs: row_document_data,\n    visible_database_view_ids: visible_database_view_ids.unwrap_or(vec![view_id]),\n    database_relations: HashMap::from([(db_oid, view_id)]),\n  };\n  Ok(serde_json::ser::to_vec(&data)?)\n}\n\npub async fn unpublish_page(\n  publish_collab_store: &dyn PublishedCollabStore,\n  workspace_id: Uuid,\n  user_uuid: Uuid,\n  view_id: Uuid,\n) -> Result<(), AppError> {\n  publish_collab_store\n    .unpublish_collabs(&workspace_id, &[view_id], &user_uuid)\n    .await\n}\n\npub async fn get_page_view_collab(\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  uid: i64,\n  workspace_id: Uuid,\n  view_id: Uuid,\n) -> Result<PageCollab, AppError> {\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let view = folder.get_view(&view_id.to_string(), uid);\n  if let Some(view) = view {\n    get_page_view_collab_for_view_with_parent(\n      &folder,\n      &view,\n      pg_pool,\n      collab_storage,\n      &workspace_id,\n      &view_id,\n      uid,\n    )\n    .await\n  } else {\n    get_page_view_collab_for_orphaned_view(pg_pool, collab_storage, &view_id, uid, &workspace_id)\n      .await\n  }\n}\n\nasync fn get_page_view_collab_for_orphaned_view(\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  view_id: &Uuid,\n  uid: i64,\n  workspace_id: &Uuid,\n) -> Result<PageCollab, AppError> {\n  let data = get_page_collab_data_for_document(collab_storage, uid, workspace_id, view_id)\n    .await\n    .map_err(|err| {\n      AppError::InvalidFolderView(format!(\n        \"Unable to get page collab data for view {}: {}\",\n        view_id, err\n      ))\n    })?;\n  let metadata = select_collab_meta_from_af_collab(pg_pool, view_id, &CollabType::Document)\n    .await?\n    .ok_or(AppError::Internal(anyhow::anyhow!(\n      \"unable to find collab metadata\"\n    )))?;\n  let owner = select_web_user_from_uid(pg_pool, metadata.owner_uid).await?;\n\n  Ok(PageCollab {\n    view: FolderView {\n      view_id: *view_id,\n      parent_view_id: None,\n      prev_view_id: None,\n      name: \"\".to_string(),\n      icon: None,\n      is_space: false,\n      is_private: false,\n      is_published: false,\n      is_favorite: false,\n      layout: ViewLayout::Document,\n      created_at: metadata.created_at.unwrap_or_default(),\n      created_by: Some(metadata.owner_uid),\n      last_edited_by: None,\n      last_edited_time: Default::default(),\n      is_locked: Some(false),\n      extra: None,\n      children: vec![],\n    },\n    data,\n    owner: owner.clone(),\n    last_editor: None,\n  })\n}\n\nasync fn get_page_view_collab_for_view_with_parent(\n  folder: &Folder,\n  view: &View,\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n  uid: i64,\n) -> Result<PageCollab, AppError> {\n  let owner = match view.created_by {\n    Some(uid) => select_web_user_from_uid(pg_pool, uid).await?,\n    None => None,\n  };\n  let last_editor = match view.last_edited_by {\n    Some(uid) => select_web_user_from_uid(pg_pool, uid).await?,\n    None => None,\n  };\n  let publish_view_ids = select_published_view_ids_for_workspace(pg_pool, *workspace_id)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Unable to obtain published view id for workspace {}: {}\",\n        workspace_id,\n        err\n      ))\n    })?;\n  let publish_view_ids: HashSet<_> = publish_view_ids.into_iter().collect();\n  let parent_view_id = Uuid::parse_str(&view.parent_view_id).ok();\n  let folder_view = FolderView {\n    view_id: *view_id,\n    parent_view_id,\n    prev_view_id: get_prev_view_id(folder, view_id, uid),\n    name: view.name.clone(),\n    icon: view\n      .icon\n      .as_ref()\n      .map(|icon| to_dto_view_icon(icon.clone())),\n    is_space: check_if_view_is_space(view),\n    is_private: false,\n    is_favorite: view.is_favorite,\n    is_published: publish_view_ids.contains(view_id),\n    layout: to_dto_view_layout(&view.layout),\n    created_at: DateTime::from_timestamp(view.created_at, 0).unwrap_or_default(),\n    created_by: view.created_by,\n    last_edited_by: view.last_edited_by,\n    last_edited_time: DateTime::from_timestamp(view.last_edited_time, 0).unwrap_or_default(),\n    is_locked: view.is_locked,\n    extra: view.extra.as_ref().map(|e| parse_extra_field_as_json(e)),\n    children: vec![],\n  };\n  let page_collab_data = match view.layout {\n    collab_folder::ViewLayout::Document => {\n      get_page_collab_data_for_document(collab_storage, uid, workspace_id, view_id).await\n    },\n    collab_folder::ViewLayout::Grid\n    | collab_folder::ViewLayout::Board\n    | collab_folder::ViewLayout::Calendar => {\n      get_page_collab_data_for_database(pg_pool, collab_storage, uid, workspace_id, view_id).await\n    },\n    collab_folder::ViewLayout::Chat => Err(AppError::InvalidRequest(\n      \"Page view for AI chat is not supported at the moment\".to_string(),\n    )),\n  }?;\n\n  let page_collab = PageCollab {\n    view: folder_view,\n    data: page_collab_data,\n    owner,\n    last_editor,\n  };\n\n  Ok(page_collab)\n}\n\nasync fn get_page_collab_data_for_database(\n  pg_pool: &PgPool,\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n) -> Result<PageCollabData, AppError> {\n  let client_id = default_client_id();\n  let ws_db_oid = select_workspace_database_oid(pg_pool, workspace_id)\n    .await\n    .map_err(|err| {\n      AppError::Internal(anyhow::anyhow!(\n        \"Unable to find workspace database oid for {}: {}\",\n        workspace_id,\n        err\n      ))\n    })?;\n  let ws_db_collab = get_latest_collab(\n    collab_storage,\n    GetCollabOrigin::User { uid },\n    *workspace_id,\n    ws_db_oid,\n    CollabType::WorkspaceDatabase,\n    client_id,\n  )\n  .await?;\n  let ws_db_body = WorkspaceDatabase::open(ws_db_collab).map_err(|err| {\n    AppError::Internal(anyhow!(\"Failed to open workspace database body: {}\", err))\n  })?;\n  let db_oid = {\n    ws_db_body\n      .get_database_meta_with_view_id(&view_id.to_string())\n      .ok_or(AppError::NoRequiredData(format!(\n        \"Database view {} not found\",\n        view_id\n      )))?\n      .database_id\n  };\n  let db = collab_storage\n    .get_full_encode_collab(\n      GetCollabOrigin::User { uid },\n      workspace_id,\n      &Uuid::parse_str(&db_oid)?,\n      CollabType::Database,\n    )\n    .await\n    .map(|v| v.encoded_collab)?;\n  let options =\n    CollabOptions::new(db_oid.to_string(), client_id).with_data_source(db.clone().into());\n  let db_collab = Collab::new_with_options(CollabOrigin::Server, options).map_err(|err| {\n    AppError::Internal(anyhow!(\n      \"Unable to create collab from object id {}: {}\",\n      &db_oid,\n      err\n    ))\n  })?;\n  let db_body = DatabaseBody::from_collab(\n    &db_collab,\n    Arc::new(NoPersistenceDatabaseCollabService::new(client_id)),\n    None,\n  )\n  .ok_or_else(|| AppError::RecordNotFound(\"no database body found\".to_string()))?;\n  let inline_view_id = {\n    let txn = db_collab.transact();\n    db_body.get_inline_view_id(&txn)\n  };\n  let row_ids: Vec<_> = {\n    let txn = db_collab.transact();\n    db_body\n      .views\n      .get_row_orders(&txn, &inline_view_id)\n      .iter()\n      .flat_map(|ro| Uuid::parse_str(&ro.id).ok())\n      .collect()\n  };\n  let queries: Vec<QueryCollab> = row_ids\n    .iter()\n    .map(|row_id| QueryCollab {\n      object_id: *row_id,\n      collab_type: CollabType::DatabaseRow,\n    })\n    .collect();\n  let row_query_collab_results = collab_storage\n    .batch_get_collab(&uid, *workspace_id, queries)\n    .await;\n  let row_data = tokio::task::spawn_blocking(move || {\n    let row_collabs: HashMap<_, _> = row_query_collab_results\n      .into_par_iter()\n      .filter_map(|(row_id, query_collab_result)| match query_collab_result {\n        QueryCollabResult::Success { encode_collab_v1 } => {\n          let decoded_result = EncodedCollab::decode_from_bytes(&encode_collab_v1);\n          match decoded_result {\n            Ok(decoded) => Some((row_id, decoded.doc_state.to_vec())),\n            Err(err) => {\n              tracing::error!(\"Failed to decode collab for row {}: {}\", row_id, err);\n              None\n            },\n          }\n        },\n        QueryCollabResult::Failed { error } => {\n          tracing::error!(\"Failed to get collab: {:?}\", error);\n          None\n        },\n      })\n      .collect();\n    row_collabs\n  })\n  .await\n  .map_err(|err| {\n    AppError::Internal(anyhow::anyhow!(\n      \"Unable to get row data for database {}: {}\",\n      &db_oid,\n      err\n    ))\n  })?;\n\n  Ok(PageCollabData {\n    encoded_collab: db.doc_state.to_vec(),\n    row_data,\n  })\n}\n\nasync fn get_page_collab_data_for_document(\n  collab_storage: &Arc<dyn CollabStore>,\n  uid: i64,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n) -> Result<PageCollabData, AppError> {\n  let collab = collab_storage\n    .get_full_encode_collab(\n      GetCollabOrigin::User { uid },\n      workspace_id,\n      view_id,\n      CollabType::Document,\n    )\n    .await\n    .map(|v| v.encoded_collab)?;\n\n  Ok(PageCollabData {\n    encoded_collab: collab.doc_state.clone().to_vec(),\n    row_data: HashMap::default(),\n  })\n}\n\n#[allow(clippy::too_many_arguments)]\npub async fn create_database_view(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  database_view_id: &Uuid,\n  view_layout: &ViewLayout,\n  name: Option<&str>,\n) -> Result<(), AppError> {\n  let database_layout = match view_layout {\n    ViewLayout::Grid => DatabaseLayout::Grid,\n    ViewLayout::Board => DatabaseLayout::Board,\n    ViewLayout::Calendar => DatabaseLayout::Calendar,\n    _ => {\n      return Err(AppError::InvalidRequest(\n        \"The layout type is not supported for database view creation\".to_string(),\n      ))\n    },\n  };\n\n  let client_id = default_client_id();\n  let timestamp = collab_database::database::timestamp();\n  let uid = user.uid;\n  let collab_origin = GetCollabOrigin::User { uid };\n  let (_, workspace_database) = get_latest_workspace_database(\n    &state.collab_storage,\n    &state.pg_pool,\n    collab_origin,\n    workspace_id,\n  )\n  .await?;\n  let database_id: Uuid = workspace_database\n    .get_database_meta_with_view_id(&database_view_id.to_string())\n    .ok_or(AppError::NoRequiredData(format!(\n      \"Database view {} not found\",\n      database_view_id\n    )))?\n    .database_id\n    .parse()?;\n  let mut database_collab = get_latest_collab(\n    &state.collab_storage,\n    GetCollabOrigin::User { uid },\n    workspace_id,\n    database_id,\n    CollabType::Database,\n    client_id,\n  )\n  .await?;\n\n  let database_body = DatabaseBody::from_collab(\n    &database_collab,\n    Arc::new(NoPersistenceDatabaseCollabService::new(client_id)),\n    None,\n  )\n  .ok_or_else(|| AppError::RecordNotFound(\"no database body found\".to_string()))?;\n  let (row_orders, field_orders, fields) = {\n    let txn = database_collab.transact();\n    let inline_view_id = database_body.get_inline_view_id(&txn);\n    let row_orders = database_body.views.get_row_orders(&txn, &inline_view_id);\n    let field_orders = database_body.views.get_field_orders(&txn, &inline_view_id);\n    let fields = database_body.fields.get_all_fields(&txn);\n    (row_orders, field_orders, fields)\n  };\n  let LinkedViewDependencies {\n    layout_settings,\n    field_settings,\n    group_settings,\n    deps_fields,\n  } = resolve_dependencies_when_create_database_linked_view(database_layout, &fields)?;\n  let new_view_id = Uuid::new_v4();\n  let database_encoded_update = {\n    let mut txn = database_collab.transact_mut();\n    let deps_field_setting = vec![default_field_settings_by_layout_map()];\n    let params = CreateViewParams {\n      database_id: database_id.to_string(),\n      view_id: new_view_id.to_string(),\n      name: name.unwrap_or_default().to_string(),\n      layout: database_layout,\n      layout_settings,\n      filters: vec![],\n      group_settings,\n      sorts: vec![],\n      field_settings,\n      created_at: timestamp,\n      modified_at: timestamp,\n      deps_fields,\n      deps_field_setting,\n    };\n    database_body\n      .create_linked_view(&mut txn, params, field_orders, row_orders)\n      .map_err(|err| {\n        AppError::Internal(anyhow!(\n          \"Unable to create linked view for database view {}: {}\",\n          database_view_id,\n          err\n        ))\n      })?;\n    txn.encode_update_v1()\n  };\n  let collab_origin = GetCollabOrigin::User { uid };\n  let (workspace_database_id, mut workspace_database) = get_latest_workspace_database(\n    &state.collab_storage,\n    &state.pg_pool,\n    collab_origin.clone(),\n    workspace_id,\n  )\n  .await?;\n  let workspace_database_update = add_new_database_view_for_workspace_database(\n    &mut workspace_database,\n    &database_id.to_string(),\n    &new_view_id,\n  )\n  .await?;\n  let mut folder = state.ws_server.get_folder(workspace_id).await?;\n  let folder_update = add_new_view_to_folder(\n    uid,\n    database_view_id,\n    &new_view_id,\n    &mut folder,\n    name,\n    to_folder_view_layout(view_layout.clone()),\n  )\n  .await?;\n\n  update_database_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    workspace_id,\n    database_id,\n    database_encoded_update,\n  )\n  .await?;\n  update_workspace_database_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user.clone(),\n    workspace_id,\n    workspace_database_id,\n    workspace_database_update,\n  )\n  .await?;\n  update_workspace_folder_data(\n    &state.metrics.appflowy_web_metrics,\n    &state.ws_server,\n    user,\n    workspace_id,\n    folder_update,\n  )\n  .await?;\n\n  Ok(())\n}\n\n#[instrument(level = \"debug\", skip_all)]\n#[allow(clippy::too_many_arguments)]\nasync fn update_collab_data_with_timeout(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  update: Vec<u8>,\n  error_context: &str,\n) -> Result<(), AppError> {\n  appflowy_web_metrics.record_update_size_bytes(update.len());\n\n  let origin = CollabOrigin::Client(CollabClient::new(user.uid, user.device_id.clone()));\n  let result =\n    update_publisher.publish_update(workspace_id, object_id, collab_type, &origin, update);\n\n  let resp = timeout_at(\n    tokio::time::Instant::now() + Duration::from_millis(2000),\n    result,\n  )\n  .await\n  .map_err(|err| {\n    appflowy_web_metrics.incr_apply_update_timeout_count(1);\n    AppError::Internal(anyhow!(\n      \"Failed to receive apply update within timeout: {}\",\n      err\n    ))\n  })?;\n\n  match resp {\n    Ok(_) => Ok(()),\n    Err(err) => {\n      appflowy_web_metrics.incr_apply_update_failure_count(1);\n      Err(AppError::Internal(anyhow!(\n        \"Failed to apply {} update: {}\",\n        error_context,\n        err\n      )))\n    },\n  }\n}\n\n#[instrument(level = \"debug\", skip_all)]\npub async fn update_page_collab_data(\n  state: &AppState,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  object_id: Uuid,\n  collab_type: CollabType,\n  doc_state: Vec<u8>,\n) -> Result<(), AppError> {\n  state\n    .metrics\n    .appflowy_web_metrics\n    .record_update_size_bytes(doc_state.len());\n  let origin = CollabOrigin::Client(CollabClient::new(user.uid, user.device_id.clone()));\n  state\n    .ws_server\n    .publish_update(workspace_id, object_id, collab_type, &origin, doc_state)\n    .await?;\n\n  Ok(())\n}\n\n#[instrument(level = \"debug\", skip_all)]\npub async fn update_workspace_folder_data(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  update: Vec<u8>,\n) -> Result<(), AppError> {\n  update_collab_data_with_timeout(\n    appflowy_web_metrics,\n    update_publisher,\n    user,\n    workspace_id,\n    workspace_id,\n    CollabType::Folder,\n    update,\n    \"folder\",\n  )\n  .await\n}\n\n#[instrument(level = \"debug\", skip_all)]\npub async fn update_workspace_database_data(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  workspace_database_id: Uuid,\n  update: Vec<u8>,\n) -> Result<(), AppError> {\n  update_collab_data_with_timeout(\n    appflowy_web_metrics,\n    update_publisher,\n    user,\n    workspace_id,\n    workspace_database_id,\n    CollabType::WorkspaceDatabase,\n    update,\n    \"workspace database\",\n  )\n  .await\n}\n\n#[instrument(level = \"debug\", skip_all)]\npub async fn update_database_data(\n  appflowy_web_metrics: &AppFlowyWebMetrics,\n  update_publisher: &impl CollabUpdatePublisher,\n  user: RealtimeUser,\n  workspace_id: Uuid,\n  database_id: Uuid,\n  update: Vec<u8>,\n) -> Result<(), AppError> {\n  update_collab_data_with_timeout(\n    appflowy_web_metrics,\n    update_publisher,\n    user,\n    workspace_id,\n    database_id,\n    CollabType::Database,\n    update,\n    \"database\",\n  )\n  .await\n}\n\npub async fn update_page_mention(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n  uid: i64,\n  update: &PageMentionUpdate,\n) -> Result<(), AppError> {\n  upsert_page_mention(pg_pool, workspace_id, view_id, uid, update).await?;\n  Ok(())\n}\n\npub async fn list_page_mentionable_persons_with_access(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n) -> Result<Vec<MentionablePersonWithAccess>, AppError> {\n  let mentionable_workspace_members_or_guests =\n    select_workspace_mentionable_members_or_guests(pg_pool, workspace_id).await?;\n  let user_uuid_with_access =\n    get_all_user_uuids_with_access_to_page(collab_instance_cache, pg_pool, workspace_id, view_id)\n      .await?;\n  let mentionable_persons: Vec<MentionablePerson> = mentionable_workspace_members_or_guests\n    .into_iter()\n    .map(|person| person.into())\n    .collect();\n  let mentionable_persons_with_access: Vec<MentionablePersonWithAccess> = mentionable_persons\n    .into_iter()\n    .map(|person| {\n      let person_id = person.uuid;\n      MentionablePersonWithAccess {\n        person,\n        can_access_page: user_uuid_with_access.contains(&person_id),\n      }\n    })\n    .collect();\n  Ok(mentionable_persons_with_access)\n}\n\npub async fn get_all_user_uuids_with_access_to_page(\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n) -> Result<Vec<Uuid>, AppError> {\n  let folder = collab_instance_cache.get_folder(*workspace_id).await?;\n  let space = get_space_view_for_current_view(&folder, &view_id.to_string(), DUMMY_UID).ok_or(\n    AppError::Internal(anyhow::anyhow!(\n      \"unable to get space for view id {}\",\n      view_id\n    )),\n  )?;\n  let is_private = check_if_space_is_private(&folder, &space.id);\n  let mut all_access: Vec<Uuid> = vec![];\n  let member_access = if is_private {\n    let space_owner_uid = space.created_by.ok_or(AppError::Internal(anyhow::anyhow!(\n      \"unable to find view owner for view: {}\",\n      view_id\n    )))?;\n    let space_owner_uuid = select_uuid_from_uid(pg_pool, space_owner_uid).await?;\n    vec![space_owner_uuid]\n  } else {\n    select_workspace_member_uuid_exclude_guest(pg_pool, workspace_id).await?\n  };\n  let guest_access =\n    get_all_user_uuids_with_guest_access_to_page(&folder, pg_pool, workspace_id, view_id).await?;\n  all_access.extend(member_access);\n  all_access.extend(guest_access);\n  Ok(all_access)\n}\n\nasync fn get_all_user_uuids_with_guest_access_to_page(\n  _folder: &Folder,\n  _pg_pool: &PgPool,\n  _workspace_id: &Uuid,\n  _view_id: &Uuid,\n) -> Result<Vec<Uuid>, AppError> {\n  // Note: the open source version of AppFlowy Cloud does not support guest access, hence this will always return empty.\n  Ok(vec![])\n}\n"
  },
  {
    "path": "src/biz/workspace/publish.rs",
    "content": "use database::{\n  publish::{\n    insert_non_orginal_workspace_publish_namespace, select_all_published_collab_info,\n    select_default_published_view_id, select_default_published_view_id_for_namespace,\n    select_workspace_publish_namespace, select_workspace_publish_namespaces,\n    update_published_collabs, update_workspace_default_publish_view,\n    update_workspace_default_publish_view_set_null,\n  },\n  workspace::{select_publish_name_exists, select_view_id_from_publish_name},\n};\nuse database_entity::dto::PatchPublishedCollab;\nuse std::sync::Arc;\n\nuse app_error::AppError;\nuse async_trait::async_trait;\nuse aws_sdk_s3::primitives::ByteStream;\nuse database_entity::dto::{PublishCollabItem, PublishInfo};\nuse shared_entity::dto::{\n  publish_dto::PublishViewMetaData,\n  workspace_dto::{FolderViewMinimal, PublishInfoView},\n};\nuse sqlx::PgPool;\nuse tracing::debug;\nuse uuid::Uuid;\n\nuse database::{\n  file::{s3_client_impl::AwsS3BucketClientImpl, BucketClient, ResponseBlob},\n  publish::{\n    insert_or_replace_publish_collabs, select_publish_collab_meta, select_published_collab_blob,\n    select_published_collab_info, select_published_collab_workspace_view_id,\n    select_published_data_for_view_id, select_published_metadata_for_view_id,\n    select_user_is_collab_publisher_for_all_views, select_workspace_publish_namespace_exists,\n    set_published_collabs_as_unpublished, update_non_orginal_workspace_publish_namespace,\n  },\n  workspace::select_user_is_workspace_owner,\n};\n\nuse crate::{\n  api::metrics::PublishedCollabMetrics, biz::collab::folder_view::to_dto_folder_view_miminal,\n};\n\nuse appflowy_collaborate::ws2::WorkspaceCollabInstanceCache;\n\nasync fn check_workspace_owner_or_publisher(\n  pg_pool: &PgPool,\n  user_uuid: &Uuid,\n  workspace_id: &Uuid,\n  view_id: &[Uuid],\n) -> Result<(), AppError> {\n  let is_owner = select_user_is_workspace_owner(pg_pool, user_uuid, workspace_id).await?;\n  if !is_owner {\n    let is_publisher =\n      select_user_is_collab_publisher_for_all_views(pg_pool, user_uuid, workspace_id, view_id)\n        .await?;\n    if !is_publisher {\n      return Err(AppError::UserUnAuthorized(\n        \"User is not the owner of the workspace or the publisher of the document\".to_string(),\n      ));\n    }\n  }\n  Ok(())\n}\n\nfn check_collab_publish_name(publish_name: &str) -> Result<(), AppError> {\n  const MAX_PUBLISH_NAME_LENGTH: usize = 128;\n\n  // Check len\n  if publish_name.len() > MAX_PUBLISH_NAME_LENGTH {\n    return Err(AppError::PublishNameTooLong {\n      given_length: publish_name.len(),\n      max_length: MAX_PUBLISH_NAME_LENGTH,\n    });\n  }\n\n  // Only contain alphanumeric characters and hyphens\n  for c in publish_name.chars() {\n    if !c.is_alphanumeric() && c != '-' && c != '_' {\n      return Err(AppError::PublishNameInvalidCharacter { character: c });\n    }\n  }\n\n  Ok(())\n}\n\nfn get_collab_s3_key(workspace_id: &Uuid, view_id: &Uuid) -> String {\n  format!(\"published-collab/{}/{}\", workspace_id, view_id)\n}\n\npub async fn set_workspace_namespace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  old_namespace: &str,\n  new_namespace: &str,\n) -> Result<(), AppError> {\n  check_workspace_namespace(new_namespace).await?;\n  if select_workspace_publish_namespace_exists(pg_pool, new_namespace).await? {\n    return Err(AppError::PublishNamespaceAlreadyTaken(\n      \"publish namespace is already taken\".to_string(),\n    ));\n  };\n  let ws_namespace =\n    select_workspace_publish_namespace(pg_pool, workspace_id, old_namespace).await?;\n  if ws_namespace.is_original {\n    insert_non_orginal_workspace_publish_namespace(pg_pool, workspace_id, new_namespace).await?;\n  } else {\n    update_non_orginal_workspace_publish_namespace(\n      pg_pool,\n      workspace_id,\n      old_namespace,\n      new_namespace,\n    )\n    .await?;\n  }\n  Ok(())\n}\n\npub async fn set_workspace_default_publish_view(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  new_view_id: &Uuid,\n) -> Result<(), AppError> {\n  update_workspace_default_publish_view(pg_pool, workspace_id, new_view_id).await?;\n  Ok(())\n}\n\npub async fn unset_workspace_default_publish_view(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<(), AppError> {\n  update_workspace_default_publish_view_set_null(pg_pool, workspace_id).await?;\n  Ok(())\n}\n\npub async fn get_workspace_default_publish_view_info(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<PublishInfo, AppError> {\n  let view_id = select_default_published_view_id(pg_pool, workspace_id)\n    .await?\n    .ok_or_else(|| {\n      AppError::RecordNotFound(format!(\n        \"Default published view not found for workspace_id: {}\",\n        workspace_id\n      ))\n    })?;\n\n  let pub_info = select_published_collab_info(pg_pool, &view_id).await?;\n  Ok(pub_info)\n}\n\npub async fn get_workspace_default_publish_view_info_meta(\n  pg_pool: &PgPool,\n  namespace: &str,\n) -> Result<(PublishInfo, serde_json::Value), AppError> {\n  let view_id = select_default_published_view_id_for_namespace(pg_pool, namespace)\n    .await?\n    .ok_or_else(|| {\n      AppError::RecordNotFound(format!(\n        \"Default published view not found for namespace: {}\",\n        namespace\n      ))\n    })?;\n\n  let (pub_info, meta) = tokio::try_join!(\n    select_published_collab_info(pg_pool, &view_id),\n    select_published_metadata_for_view_id(pg_pool, &view_id)\n  )?;\n  let meta = meta.ok_or_else(|| {\n    AppError::RecordNotFound(format!(\n      \"Published metadata not found for view_id: {}\",\n      view_id\n    ))\n  })?;\n\n  Ok((pub_info, meta.1))\n}\n\npub async fn get_workspace_publish_namespace(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n) -> Result<String, AppError> {\n  let mut ws_namespaces = select_workspace_publish_namespaces(pg_pool, workspace_id).await?;\n  match ws_namespaces.len() {\n    0 => Err(AppError::RecordNotFound(format!(\n      \"No publish namespace found for workspace_id: {}\",\n      workspace_id\n    ))),\n    1 => Ok(ws_namespaces.remove(0).namespace),\n    _ => {\n      for ws_namespace in ws_namespaces {\n        if !ws_namespace.is_original {\n          return Ok(ws_namespace.namespace);\n        }\n      }\n      Err(AppError::RecordNotFound(format!(\n        \"Cannot find non-original publish namespace for workspace_id: {}\",\n        workspace_id\n      )))\n    },\n  }\n}\n\npub async fn list_collab_publish_info(\n  publish_collab_store: &dyn PublishedCollabStore,\n  collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  workspace_id: Uuid,\n  uid: i64,\n) -> Result<Vec<PublishInfoView>, AppError> {\n  let folder = collab_instance_cache.get_folder(workspace_id).await?;\n  let publish_infos = publish_collab_store\n    .list_collab_publish_info(&workspace_id)\n    .await?;\n\n  let mut publish_info_views: Vec<PublishInfoView> = Vec::with_capacity(publish_infos.len());\n  for publish_info in publish_infos {\n    let view_id = publish_info.view_id.to_string();\n    match folder.get_view(&view_id, uid) {\n      Some(view) => {\n        publish_info_views.push(PublishInfoView {\n          view: to_dto_folder_view_miminal(&view),\n          info: publish_info,\n        });\n      },\n      None => {\n        tracing::error!(\"View {} not found in folder but is published\", view_id);\n        publish_info_views.push(PublishInfoView {\n          view: FolderViewMinimal {\n            view_id,\n            name: publish_info.publish_name.clone(),\n            ..Default::default()\n          },\n          info: publish_info,\n        });\n      },\n    };\n  }\n\n  Ok(publish_info_views)\n}\n\nasync fn check_workspace_namespace(new_namespace: &str) -> Result<(), AppError> {\n  // Must be url safe\n  // Only contain alphanumeric characters and hyphens\n  // and underscores (discouraged)\n  for c in new_namespace.chars() {\n    if !c.is_alphanumeric() && c != '-' && c != '_' {\n      return Err(AppError::CustomNamespaceInvalidCharacter { character: c });\n    }\n  }\n  Ok(())\n}\n\n#[async_trait]\npub trait PublishedCollabStore: Sync + Send + 'static {\n  async fn publish_collabs(\n    &self,\n    published_items: Vec<PublishCollabItem<serde_json::Value, Vec<u8>>>,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError>;\n\n  async fn get_collab_with_view_metadata_by_view_id(\n    &self,\n    view_id: &Uuid,\n  ) -> Result<Option<(PublishViewMetaData, Vec<u8>)>, AppError>;\n\n  async fn get_collab_metadata(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<serde_json::Value, AppError>;\n\n  async fn list_collab_publish_info(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<PublishInfo>, AppError>;\n\n  async fn get_collab_publish_info(&self, view_id: &Uuid) -> Result<PublishInfo, AppError>;\n\n  async fn get_collab_blob_by_publish_namespace(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<Vec<u8>, AppError>;\n\n  async fn unpublish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    view_ids: &[Uuid],\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError>;\n\n  async fn patch_collabs(\n    &self,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n    patches: &[PatchPublishedCollab],\n  ) -> Result<(), AppError>;\n}\n\npub struct PublishedCollabPostgresStore {\n  metrics: Arc<PublishedCollabMetrics>,\n  pg_pool: PgPool,\n}\n\nimpl PublishedCollabPostgresStore {\n  pub fn new(metrics: Arc<PublishedCollabMetrics>, pg_pool: PgPool) -> Self {\n    Self { metrics, pg_pool }\n  }\n}\n\n#[async_trait]\nimpl PublishedCollabStore for PublishedCollabPostgresStore {\n  async fn publish_collabs(\n    &self,\n    publish_items: Vec<PublishCollabItem<serde_json::Value, Vec<u8>>>,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError> {\n    for publish_item in &publish_items {\n      check_collab_publish_name(publish_item.meta.publish_name.as_str())?;\n      check_view_id_publish_name_conflict(\n        &self.pg_pool,\n        workspace_id,\n        &publish_item.meta.view_id,\n        publish_item.meta.publish_name.as_str(),\n      )\n      .await?;\n    }\n    let publish_items_batch_size = publish_items.len() as i64;\n    let result =\n      insert_or_replace_publish_collabs(&self.pg_pool, workspace_id, user_uuid, publish_items)\n        .await;\n    if result.is_err() {\n      self\n        .metrics\n        .incr_failure_write_count(publish_items_batch_size);\n    } else {\n      self\n        .metrics\n        .incr_success_write_count(publish_items_batch_size);\n    }\n    result\n  }\n\n  async fn get_collab_metadata(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<serde_json::Value, AppError> {\n    let metadata =\n      select_publish_collab_meta(&self.pg_pool, publish_namespace, publish_name).await?;\n    Ok(metadata)\n  }\n\n  async fn get_collab_with_view_metadata_by_view_id(\n    &self,\n    view_id: &Uuid,\n  ) -> Result<Option<(PublishViewMetaData, Vec<u8>)>, AppError> {\n    let result = match select_published_data_for_view_id(&self.pg_pool, view_id).await? {\n      Some((js_val, blob)) => {\n        let metadata = serde_json::from_value(js_val)?;\n        Ok(Some((metadata, blob)))\n      },\n      None => Ok(None),\n    };\n    if result.is_err() {\n      self.metrics.incr_failure_read_count(1);\n    } else {\n      self.metrics.incr_success_read_count(1);\n    }\n    result\n  }\n\n  async fn list_collab_publish_info(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<PublishInfo>, AppError> {\n    select_all_published_collab_info(&self.pg_pool, workspace_id).await\n  }\n\n  async fn get_collab_publish_info(&self, view_id: &Uuid) -> Result<PublishInfo, AppError> {\n    select_published_collab_info(&self.pg_pool, view_id).await\n  }\n\n  async fn get_collab_blob_by_publish_namespace(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<Vec<u8>, AppError> {\n    let result = select_published_collab_blob(&self.pg_pool, publish_namespace, publish_name).await;\n    if result.is_err() {\n      self.metrics.incr_failure_read_count(1);\n    } else {\n      self.metrics.incr_success_read_count(1);\n    }\n    result\n  }\n\n  async fn unpublish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    view_ids: &[Uuid],\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError> {\n    check_workspace_owner_or_publisher(&self.pg_pool, user_uuid, workspace_id, view_ids).await?;\n    set_published_collabs_as_unpublished(&self.pg_pool, workspace_id, view_ids).await?;\n    Ok(())\n  }\n\n  async fn patch_collabs(\n    &self,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n    patches: &[PatchPublishedCollab],\n  ) -> Result<(), AppError> {\n    patch_collabs(&self.pg_pool, workspace_id, user_uuid, patches).await\n  }\n}\n\npub struct PublishedCollabS3StoreWithPostgresFallback {\n  metrics: Arc<PublishedCollabMetrics>,\n  pg_pool: PgPool,\n  bucket_client: AwsS3BucketClientImpl,\n}\n\nimpl PublishedCollabS3StoreWithPostgresFallback {\n  pub fn new(\n    metrics: Arc<PublishedCollabMetrics>,\n    pg_pool: PgPool,\n    bucket_client: AwsS3BucketClientImpl,\n  ) -> Self {\n    Self {\n      metrics,\n      pg_pool,\n      bucket_client,\n    }\n  }\n}\n\n#[async_trait]\nimpl PublishedCollabStore for PublishedCollabS3StoreWithPostgresFallback {\n  async fn publish_collabs(\n    &self,\n    publish_items: Vec<PublishCollabItem<serde_json::Value, Vec<u8>>>,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError> {\n    let publish_items_batch_size = publish_items.len() as i64;\n    let mut handles: Vec<tokio::task::JoinHandle<()>> = vec![];\n    for publish_item in &publish_items {\n      check_collab_publish_name(publish_item.meta.publish_name.as_str())?;\n      check_view_id_publish_name_conflict(\n        &self.pg_pool,\n        workspace_id,\n        &publish_item.meta.view_id,\n        publish_item.meta.publish_name.as_str(),\n      )\n      .await?;\n\n      let object_key = get_collab_s3_key(workspace_id, &publish_item.meta.view_id);\n      let data = publish_item.data.clone();\n      let bucket_client = self.bucket_client.clone();\n      let metrics = self.metrics.clone();\n      let handle = tokio::spawn(async move {\n        let body = ByteStream::from(data);\n        let result = bucket_client.put_blob(&object_key, body, None).await;\n        if let Err(err) = result {\n          debug!(\"Failed to publish collab to S3: {}\", err);\n        } else {\n          metrics.incr_success_write_count(1);\n        }\n      });\n      handles.push(handle);\n    }\n    for handle in handles {\n      handle.await?;\n    }\n\n    let result =\n      insert_or_replace_publish_collabs(&self.pg_pool, workspace_id, user_uuid, publish_items)\n        .await;\n    if result.is_err() {\n      self\n        .metrics\n        .incr_failure_write_count(publish_items_batch_size);\n    } else {\n      self\n        .metrics\n        .incr_fallback_write_count(publish_items_batch_size);\n    }\n    result\n  }\n\n  async fn get_collab_metadata(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<serde_json::Value, AppError> {\n    let metadata =\n      select_publish_collab_meta(&self.pg_pool, publish_namespace, publish_name).await?;\n    Ok(metadata)\n  }\n\n  async fn get_collab_with_view_metadata_by_view_id(\n    &self,\n    view_id: &Uuid,\n  ) -> Result<Option<(PublishViewMetaData, Vec<u8>)>, AppError> {\n    let result = select_published_metadata_for_view_id(&self.pg_pool, view_id).await?;\n    match result {\n      Some((workspace_id, js_val)) => {\n        let metadata = serde_json::from_value(js_val)?;\n        let object_key = get_collab_s3_key(&workspace_id, view_id);\n        match self.bucket_client.get_blob(&object_key).await {\n          Ok(resp) => {\n            self.metrics.incr_success_read_count(1);\n            Ok(Some((metadata, resp.to_blob())))\n          },\n          Err(_) => {\n            let result = match select_published_data_for_view_id(&self.pg_pool, view_id).await? {\n              Some((js_val, blob)) => {\n                let metadata = serde_json::from_value(js_val)?;\n                Ok(Some((metadata, blob)))\n              },\n              None => Ok(None),\n            };\n            if result.is_err() {\n              self.metrics.incr_failure_read_count(1);\n            } else {\n              self.metrics.incr_fallback_read_count(1);\n            }\n            result\n          },\n        }\n      },\n      None => {\n        self.metrics.incr_success_read_count(1);\n        Ok(None)\n      },\n    }\n  }\n\n  async fn get_collab_publish_info(&self, view_id: &Uuid) -> Result<PublishInfo, AppError> {\n    select_published_collab_info(&self.pg_pool, view_id).await\n  }\n\n  async fn list_collab_publish_info(\n    &self,\n    workspace_id: &Uuid,\n  ) -> Result<Vec<PublishInfo>, AppError> {\n    select_all_published_collab_info(&self.pg_pool, workspace_id).await\n  }\n\n  async fn get_collab_blob_by_publish_namespace(\n    &self,\n    publish_namespace: &str,\n    publish_name: &str,\n  ) -> Result<Vec<u8>, AppError> {\n    let collab_key =\n      select_published_collab_workspace_view_id(&self.pg_pool, publish_namespace, publish_name)\n        .await?;\n    let object_key = get_collab_s3_key(&collab_key.workspace_id, &collab_key.view_id);\n    let resp = self.bucket_client.get_blob(&object_key).await;\n    match resp {\n      Ok(resp) => {\n        self.metrics.incr_success_read_count(1);\n        Ok(resp.to_blob())\n      },\n      Err(err) => {\n        debug!(\n          \"Failed to get published collab blob {} from S3 due to {}\",\n          object_key, err\n        );\n        let result =\n          select_published_collab_blob(&self.pg_pool, publish_namespace, publish_name).await;\n        if result.is_err() {\n          self.metrics.incr_failure_read_count(1);\n        } else {\n          self.metrics.incr_fallback_read_count(1);\n        }\n        result\n      },\n    }\n  }\n\n  async fn unpublish_collabs(\n    &self,\n    workspace_id: &Uuid,\n    view_ids: &[Uuid],\n    user_uuid: &Uuid,\n  ) -> Result<(), AppError> {\n    check_workspace_owner_or_publisher(&self.pg_pool, user_uuid, workspace_id, view_ids).await?;\n    let object_keys = view_ids\n      .iter()\n      .map(|view_id| get_collab_s3_key(workspace_id, view_id))\n      .collect::<Vec<String>>();\n    self.bucket_client.delete_blobs(object_keys).await?;\n    set_published_collabs_as_unpublished(&self.pg_pool, workspace_id, view_ids).await?;\n    Ok(())\n  }\n\n  async fn patch_collabs(\n    &self,\n    workspace_id: &Uuid,\n    user_uuid: &Uuid,\n    patches: &[PatchPublishedCollab],\n  ) -> Result<(), AppError> {\n    patch_collabs(&self.pg_pool, workspace_id, user_uuid, patches).await\n  }\n}\n\nasync fn patch_collabs(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  user_uuid: &Uuid,\n  patches: &[PatchPublishedCollab],\n) -> Result<(), AppError> {\n  let view_ids = patches\n    .iter()\n    .map(|patch| patch.view_id)\n    .collect::<Vec<Uuid>>();\n  for patch in patches {\n    if let Some(new_publish_name) = patch.publish_name.as_deref() {\n      check_collab_publish_name(new_publish_name)?;\n      check_publish_name_already_exists(pg_pool, workspace_id, new_publish_name).await?;\n    }\n  }\n  check_workspace_owner_or_publisher(pg_pool, user_uuid, workspace_id, &view_ids).await?;\n\n  let mut txn = pg_pool.begin().await?;\n  update_published_collabs(&mut txn, workspace_id, patches).await?;\n  txn.commit().await?;\n  Ok(())\n}\n\n/// Checks if the `publish_name` already exists for the workspace\nasync fn check_publish_name_already_exists(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  publish_name: &str,\n) -> Result<(), AppError> {\n  let publish_name_exists = select_publish_name_exists(pg_pool, workspace_id, publish_name).await?;\n  if publish_name_exists {\n    return Err(AppError::PublishNameAlreadyExists {\n      workspace_id: *workspace_id,\n      publish_name: publish_name.to_string(),\n    });\n  }\n  Ok(())\n}\n\n/// Check if the `publish_name` already exists on another view\nasync fn check_view_id_publish_name_conflict(\n  pg_pool: &PgPool,\n  workspace_id: &Uuid,\n  view_id: &Uuid,\n  publish_name: &str,\n) -> Result<(), AppError> {\n  match select_view_id_from_publish_name(pg_pool, workspace_id, publish_name).await? {\n    Some(published_view_id) => {\n      if published_view_id != *view_id {\n        Err(AppError::PublishNameAlreadyExists {\n          workspace_id: *workspace_id,\n          publish_name: publish_name.to_string(),\n        })\n      } else {\n        Ok(())\n      }\n    },\n    None => Ok(()),\n  }\n}\n"
  },
  {
    "path": "src/biz/workspace/publish_dup.rs",
    "content": "use app_error::AppError;\nuse collab_database::database::DatabaseBody;\nuse collab_database::entity::FieldType;\nuse collab_database::rows::meta_id_from_row_id;\nuse collab_database::rows::DatabaseRowBody;\nuse collab_database::rows::RowMetaKey;\nuse collab_database::rows::CELL_FIELD_TYPE;\nuse collab_database::rows::ROW_CELLS;\nuse collab_database::template::entity::CELL_DATA;\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_document::blocks::DocumentData;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::{CollabOrigin, RepeatedViewIdentifier, View};\nuse database::collab::GetCollabOrigin;\nuse database::collab::{select_workspace_database_oid, CollabStore};\nuse database::file::s3_client_impl::AwsS3BucketClientImpl;\nuse database::file::BucketClient;\nuse database::file::ResponseBlob;\nuse database::publish::select_published_data_for_view_id;\nuse database::publish::select_published_metadata_for_view_id;\nuse database_entity::dto::CollabParams;\nuse shared_entity::dto::publish_dto::PublishDatabaseDataWithNonUuidRelations;\nuse shared_entity::dto::publish_dto::{PublishDatabaseData, PublishViewInfo, PublishViewMetaData};\nuse shared_entity::dto::workspace_dto::ViewLayout;\nuse sqlx::PgPool;\nuse std::collections::HashSet;\nuse std::time::{Duration, Instant};\nuse std::{collections::HashMap, sync::Arc};\n\nuse crate::biz::collab::folder_view::to_folder_view_icon;\nuse crate::biz::collab::folder_view::to_folder_view_layout;\nuse crate::biz::collab::utils::{collab_from_doc_state, get_latest_collab};\nuse tracing::error;\nuse uuid::Uuid;\nuse workspace_template::gen_view_id;\nuse yrs::Any;\nuse yrs::Array;\nuse yrs::ArrayRef;\nuse yrs::Out;\nuse yrs::{Map, MapRef};\n\nuse crate::biz::collab::utils::collab_to_bin;\n\nuse crate::state::AppState;\nuse appflowy_collaborate::ws2::{CollabUpdatePublisher, WorkspaceCollabInstanceCache};\nuse appflowy_collaborate::CollabMetrics;\nuse collab::core::collab::default_client_id;\nuse collab_database::database_trait::NoPersistenceDatabaseCollabService;\n\n#[allow(clippy::too_many_arguments)]\npub async fn duplicate_published_collab_to_workspace(\n  state: &AppState,\n  dest_uid: i64,\n  publish_view_id: Uuid,\n  dest_workspace_id: Uuid,\n  dest_view_id: Uuid,\n) -> Result<Uuid, AppError> {\n  let copier = PublishCollabDuplicator::new(\n    state.pg_pool.clone(),\n    state.bucket_client.clone(),\n    state.collab_storage.clone(),\n    Box::new(state.ws_server.clone()),\n    dest_uid,\n    dest_workspace_id,\n    dest_view_id,\n    state.metrics.collab_metrics.clone(),\n  );\n\n  let time_now = chrono::Utc::now().timestamp_millis();\n  let root_view_id_for_duplicate = copier.duplicate(publish_view_id, &state.ws_server).await?;\n  let elapsed = chrono::Utc::now().timestamp_millis() - time_now;\n  tracing::info!(\n    \"duplicate_published_collab_to_workspace: elapsed time: {}ms\",\n    elapsed\n  );\n  Ok(root_view_id_for_duplicate)\n}\n\npub struct PublishCollabDuplicator {\n  /// for fetching and writing folder data\n  /// of dest workspace\n  collab_storage: Arc<dyn CollabStore>,\n  /// A map to store the old view_id that was duplicated and new view_id assigned.\n  /// If value is none, it means the view_id is not published.\n  duplicated_refs: HashMap<Uuid, Option<Uuid>>,\n  /// published_database_id -> view_id\n  duplicated_db_main_view: HashMap<Uuid, Uuid>,\n  /// published_database_view_id -> new_view_id\n  duplicated_db_view: HashMap<Uuid, Uuid>,\n  /// published_database_row_id -> new_row_id\n  duplicated_db_row: HashMap<Uuid, Uuid>,\n  /// new views to be added to the folder\n  /// view_id -> view\n  views_to_add: HashMap<Uuid, View>,\n  /// A list of database linked views to be added to workspace database\n  workspace_databases: HashMap<Uuid, Vec<Uuid>>,\n  /// A list of collab objects to added to the workspace (oid -> collab)\n  collabs_to_insert: HashMap<Uuid, (CollabType, Vec<u8>)>,\n  /// time of duplication\n  ts_now: i64,\n  /// for fetching published data\n  /// and writing them to dest workspace\n  pg_pool: PgPool,\n  /// for fetching published data from s3\n  bucket_client: AwsS3BucketClientImpl,\n  /// user initiating the duplication\n  duplicator_uid: i64,\n  /// workspace to duplicate into\n  dest_workspace_id: Uuid,\n  /// view of workspace to duplicate into\n  dest_view_id: Uuid,\n  collab_update_publisher: Box<dyn CollabUpdatePublisher>,\n  collab_metrics: Arc<CollabMetrics>,\n}\n\nfn deserialize_publish_database_data(\n  published_blob: &[u8],\n) -> Result<PublishDatabaseData, AppError> {\n  match serde_json::from_slice::<PublishDatabaseData>(published_blob) {\n    Ok(payload) => Ok(payload),\n    Err(_) => {\n      match serde_json::from_slice::<PublishDatabaseDataWithNonUuidRelations>(published_blob) {\n        Ok(payload) => Ok(payload.into()),\n        Err(err) => Err(AppError::from(err)),\n      }\n    },\n  }\n}\n\nimpl PublishCollabDuplicator {\n  #[allow(clippy::too_many_arguments)]\n  pub fn new(\n    pg_pool: PgPool,\n    bucket_client: AwsS3BucketClientImpl,\n    collab_storage: Arc<dyn CollabStore>,\n\n    collab_update_publisher: Box<dyn CollabUpdatePublisher>,\n    dest_uid: i64,\n    dest_workspace_id: Uuid,\n    dest_view_id: Uuid,\n    collab_metrics: Arc<CollabMetrics>,\n  ) -> Self {\n    let ts_now = chrono::Utc::now().timestamp();\n    Self {\n      ts_now,\n      duplicated_refs: HashMap::new(),\n      views_to_add: HashMap::new(),\n      workspace_databases: HashMap::new(),\n      collabs_to_insert: HashMap::new(),\n      duplicated_db_main_view: HashMap::new(),\n      duplicated_db_view: HashMap::new(),\n      duplicated_db_row: HashMap::new(),\n      pg_pool,\n      bucket_client,\n      collab_storage,\n      duplicator_uid: dest_uid,\n      dest_workspace_id,\n      dest_view_id,\n      collab_update_publisher,\n      collab_metrics,\n    }\n  }\n\n  async fn duplicate(\n    mut self,\n    publish_view_id: Uuid,\n    collab_instance_cache: &impl WorkspaceCollabInstanceCache,\n  ) -> Result<Uuid, AppError> {\n    // new view after deep copy\n    // this is the root of the document/database duplicated\n    let root_view_id = gen_view_id();\n    let mut root_view = match self.deep_copy(root_view_id, publish_view_id).await? {\n      Some(v) => v,\n      None => {\n        return Err(AppError::RecordNotFound(\n          \"view not found, it might be unpublished\".to_string(),\n        ))\n      },\n    };\n    root_view.parent_view_id = self.dest_view_id.to_string();\n\n    // destructuring self to own inner values, avoids cloning\n    let PublishCollabDuplicator {\n      collab_storage,\n      duplicated_refs: _,\n      duplicated_db_main_view: _,\n      duplicated_db_view: _,\n      duplicated_db_row: _,\n      mut views_to_add,\n      workspace_databases,\n      collabs_to_insert,\n      ts_now: _,\n      pg_pool,\n      bucket_client: _,\n      duplicator_uid,\n      dest_workspace_id,\n      dest_view_id,\n      collab_update_publisher: collab_update_writer,\n      collab_metrics,\n    } = self;\n\n    // insert all collab object accumulated\n    // for self.collabs_to_insert\n    let mut txn = pg_pool.begin().await?;\n    let start = Instant::now();\n    for (oid, (collab_type, encoded_collab)) in collabs_to_insert.into_iter() {\n      let params = CollabParams {\n        object_id: oid,\n        encoded_collab_v1: encoded_collab.into(),\n        collab_type,\n        updated_at: None,\n      };\n      let action = format!(\"duplicate collab: {}\", params);\n      collab_storage\n        .upsert_new_collab_with_transaction(\n          dest_workspace_id,\n          &duplicator_uid,\n          params,\n          &mut txn,\n          &action,\n        )\n        .await?;\n    }\n    match tokio::time::timeout(Duration::from_secs(60), txn.commit()).await {\n      Ok(result) => {\n        collab_metrics.observe_pg_tx(start.elapsed());\n        result.map_err(AppError::from)\n      },\n      Err(_) => {\n        error!(\"Timeout waiting for duplicating collabs\");\n        Err(AppError::RequestTimeout(\n          \"timeout while duplicating\".to_string(),\n        ))\n      },\n    }?;\n\n    // update database if any\n    if !workspace_databases.is_empty() {\n      let ws_db_oid = select_workspace_database_oid(&pg_pool, &dest_workspace_id).await?;\n      let ws_db_collab = {\n        get_latest_collab(\n          &collab_storage,\n          GetCollabOrigin::User {\n            uid: duplicator_uid,\n          },\n          dest_workspace_id,\n          ws_db_oid,\n          CollabType::WorkspaceDatabase,\n          default_client_id(),\n        )\n        .await?\n      };\n\n      let mut ws_db = WorkspaceDatabase::open(ws_db_collab).map_err(|err| {\n        AppError::Unhandled(format!(\"failed to open workspace database: {}\", err))\n      })?;\n\n      let view_ids_by_database_id = workspace_databases\n        .into_iter()\n        .map(|(database_id, view_ids)| {\n          (\n            database_id.to_string(),\n            view_ids\n              .into_iter()\n              .map(|view_id| view_id.to_string())\n              .collect(),\n          )\n        })\n        .collect::<HashMap<_, _>>();\n\n      let workspace_database_update = ws_db\n        .batch_add_database(view_ids_by_database_id)\n        .encode_update_v1();\n      collab_update_writer\n        .publish_update(\n          dest_workspace_id,\n          ws_db_oid,\n          CollabType::WorkspaceDatabase,\n          &CollabOrigin::Server,\n          workspace_database_update,\n        )\n        .await?;\n    }\n\n    let mut folder = collab_instance_cache.get_folder(dest_workspace_id).await?;\n    let folder_updates = tokio::task::spawn_blocking(move || {\n      let mut folder_txn = folder.collab.transact_mut();\n      let mut duplicated_view_ids = HashSet::new();\n      duplicated_view_ids.insert(dest_view_id);\n      duplicated_view_ids.insert(root_view.id.parse().unwrap());\n      folder\n        .body\n        .views\n        .insert(&mut folder_txn, root_view, None, duplicator_uid);\n      // when child views are added, it must have a parent view that is previously added\n      // TODO: if there are too many child views, consider using topological sort\n      loop {\n        if views_to_add.is_empty() {\n          break;\n        }\n\n        let mut inserted = vec![];\n        for (view_id, view) in views_to_add.iter() {\n          let parent_view_id = Uuid::parse_str(&view.parent_view_id).unwrap();\n          // allow to insert if parent view is already inserted\n          // or if view is standalone (view_id == parent_view_id)\n          if duplicated_view_ids.contains(&parent_view_id) || *view_id == parent_view_id {\n            folder\n              .body\n              .views\n              .insert(&mut folder_txn, view.clone(), None, duplicator_uid);\n            duplicated_view_ids.insert(*view_id);\n            inserted.push(*view_id);\n          }\n        }\n        if inserted.is_empty() {\n          tracing::error!(\n            \"views not inserted because parent_id does not exists: {:?}\",\n            views_to_add.keys()\n          );\n          break;\n        }\n        for view_id in inserted {\n          views_to_add.remove(&view_id);\n        }\n      }\n\n      folder_txn.encode_update_v1()\n    })\n    .await?;\n    collab_update_writer\n      .publish_update(\n        dest_workspace_id,\n        dest_workspace_id,\n        CollabType::Folder,\n        &CollabOrigin::Server,\n        folder_updates,\n      )\n      .await?;\n\n    Ok(root_view_id)\n  }\n\n  /// Deep copy a published collab to the destination workspace.\n  /// If None is returned, it means the view is not published.\n  /// If Some is returned, a new view is created but without parent_view_id set.\n  /// Caller should set the parent_view_id to the parent view.\n  async fn deep_copy(\n    &mut self,\n    new_view_id: Uuid,\n    publish_view_id: Uuid,\n  ) -> Result<Option<View>, AppError> {\n    tracing::info!(\n      \"deep_copy: new_view_id: {}, publish_view_id: {}\",\n      new_view_id,\n      publish_view_id,\n    );\n\n    // attempt to get metadata and doc_state for published view\n    let (metadata, published_blob) = match self\n      .get_published_data_for_view_id(&publish_view_id)\n      .await?\n    {\n      Some(published_data) => published_data,\n      None => {\n        tracing::warn!(\n          \"No published collab data found for view_id: {}\",\n          publish_view_id\n        );\n        return Ok(None);\n      },\n    };\n\n    // at this stage, we know that the view is published,\n    // so we insert this knowledge into the duplicated_refs\n    self\n      .duplicated_refs\n      .insert(publish_view_id, new_view_id.into());\n\n    match metadata.view.layout {\n      ViewLayout::Document => {\n        let doc_collab =\n          collab_from_doc_state(published_blob, &Uuid::default(), default_client_id())?;\n        let doc = Document::open(doc_collab).map_err(|e| AppError::Unhandled(e.to_string()))?;\n        let new_doc_view = self\n          .deep_copy_doc(publish_view_id, new_view_id, doc, metadata)\n          .await?;\n        Ok(Some(new_doc_view))\n      },\n      ViewLayout::Grid | ViewLayout::Board | ViewLayout::Calendar => {\n        let pub_view_id: Uuid = metadata.view.view_id.parse()?;\n        let db_payload = deserialize_publish_database_data(&published_blob)?;\n        let new_db_view = self\n          .deep_copy_database_view(new_view_id, db_payload, &metadata, &pub_view_id)\n          .await?;\n        Ok(Some(new_db_view))\n      },\n      t => {\n        tracing::warn!(\"collab type not supported: {:?}\", t);\n        Ok(None)\n      },\n    }\n  }\n\n  async fn deep_copy_doc(\n    &mut self,\n    pub_view_id: Uuid,\n    dup_view_id: Uuid,\n    doc: Document,\n    metadata: PublishViewMetaData,\n  ) -> Result<View, AppError> {\n    let mut ret_view = self.new_folder_view(dup_view_id, &metadata.view, ViewLayout::Document);\n\n    let mut doc_data = doc\n      .get_document_data()\n      .map_err(|e| AppError::Unhandled(e.to_string()))?;\n\n    if let Err(err) = self.deep_copy_doc_pages(&mut doc_data, &mut ret_view).await {\n      tracing::error!(\"failed to deep copy doc pages: {}\", err);\n    }\n\n    if let Err(err) = self\n      .deep_copy_doc_databases(&pub_view_id, &mut doc_data, &mut ret_view)\n      .await\n    {\n      tracing::error!(\"failed to deep copy doc databases: {}\", err);\n    };\n\n    {\n      // write modified doc_data back to storage\n      let empty_collab = collab_from_doc_state(vec![], &dup_view_id, default_client_id())?;\n      let new_doc = tokio::task::spawn_blocking(move || {\n        Document::create_with_data(empty_collab, doc_data)\n          .map_err(|e| AppError::Unhandled(e.to_string()))\n      })\n      .await??;\n      let new_doc_bin = collab_to_bin(new_doc.split().0, CollabType::Document).await?;\n      self\n        .collabs_to_insert\n        .insert(ret_view.id.parse()?, (CollabType::Document, new_doc_bin));\n    }\n    Ok(ret_view)\n  }\n\n  async fn deep_copy_doc_pages(\n    &mut self,\n    doc_data: &mut DocumentData,\n    ret_view: &mut View,\n  ) -> Result<(), AppError> {\n    if let Some(text_map) = doc_data.meta.text_map.as_mut() {\n      for (_key, value) in text_map.iter_mut() {\n        let mut js_val = match serde_json::from_str::<serde_json::Value>(value) {\n          Ok(js_val) => js_val,\n          Err(e) => {\n            tracing::error!(\"failed to parse text_map value({}): {}\", value, e);\n            continue;\n          },\n        };\n        let js_array = match js_val.as_array_mut() {\n          Some(js_array) => js_array,\n          None => continue,\n        };\n\n        let page_ids = js_array\n          .iter_mut()\n          .flat_map(|js_val| js_val.get_mut(\"attributes\"))\n          .flat_map(|attributes| attributes.get_mut(\"mention\"))\n          .filter(|mention| {\n            mention\n              .get(\"type\")\n              .is_some_and(|type_| type_.as_str() == Some(\"page\"))\n          })\n          .flat_map(|mention| mention.get_mut(\"page_id\"));\n\n        for page_id in page_ids {\n          let page_id_str = match page_id.as_str() {\n            Some(page_id_str) => Uuid::parse_str(page_id_str)?,\n            None => continue,\n          };\n          if let Some(new_page_id) = self\n            .deep_copy_view(page_id_str, ret_view.id.parse()?)\n            .await?\n          {\n            *page_id = serde_json::json!(new_page_id);\n          } else {\n            tracing::warn!(\"deep_copy_doc_pages: view not found: {}\", page_id_str);\n          };\n        }\n\n        *value = js_val.to_string();\n      }\n    }\n\n    Ok(())\n  }\n\n  /// Attempts to deep copy a view using `pub_view_id`.\n  /// Returns None if view is not published else\n  /// returns the view id of the duplicated view.\n  /// If view is already duplicated, returns duplicated view's view_id (parent_view_id is not set\n  /// from param `parent_view_id`)\n  async fn deep_copy_view(\n    &mut self,\n    pub_view_id: Uuid,\n    parent_view_id: Uuid,\n  ) -> Result<Option<Uuid>, AppError> {\n    match self.duplicated_refs.get(&pub_view_id) {\n      Some(new_view_id) => {\n        if let Some(vid) = new_view_id {\n          Ok(Some(*vid))\n        } else {\n          Ok(None)\n        }\n      },\n      None => {\n        // Call deep_copy and await the result\n        if let Some(mut new_view) = Box::pin(self.deep_copy(gen_view_id(), pub_view_id)).await? {\n          if new_view.parent_view_id.is_empty() {\n            new_view.parent_view_id = parent_view_id.to_string();\n          }\n          let new_view_id = Uuid::parse_str(&new_view.id)?;\n          self.duplicated_refs.insert(pub_view_id, Some(new_view_id));\n          self.views_to_add.insert(new_view_id, new_view);\n          Ok(Some(new_view_id))\n        } else {\n          tracing::warn!(\"view not found in deep_copy: {}\", pub_view_id);\n          self.duplicated_refs.insert(pub_view_id, None);\n          Ok(None)\n        }\n      },\n    }\n  }\n\n  async fn deep_copy_doc_databases(\n    &mut self,\n    pub_view_id: &Uuid,\n    doc_data: &mut DocumentData,\n    ret_view: &mut View,\n  ) -> Result<(), AppError> {\n    let db_blocks = doc_data\n      .blocks\n      .iter_mut()\n      .filter(|(_, b)| b.ty == \"grid\" || b.ty == \"board\" || b.ty == \"calendar\");\n\n    for (block_id, block) in db_blocks {\n      tracing::info!(\"deep_copy_doc_databases: block_id: {}\", block_id);\n      let block_view_id: Uuid = block\n        .data\n        .get(\"view_id\")\n        .ok_or_else(|| AppError::RecordNotFound(\"view_id not found in block data\".to_string()))?\n        .as_str()\n        .ok_or_else(|| AppError::RecordNotFound(\"view_id not a string\".to_string()))?\n        .parse()?;\n\n      let block_parent_id: Uuid = block\n        .data\n        .get(\"parent_id\")\n        .ok_or_else(|| AppError::RecordNotFound(\"view_id not found in block data\".to_string()))?\n        .as_str()\n        .ok_or_else(|| AppError::RecordNotFound(\"view_id not a string\".to_string()))?\n        .parse()?;\n\n      if pub_view_id == &block_parent_id {\n        // inline database in doc\n        if let Some(new_view_id) = self\n          .deep_copy_inline_database_in_doc(block_view_id, &ret_view.id)\n          .await?\n        {\n          block.data.insert(\n            \"view_id\".to_string(),\n            serde_json::Value::String(new_view_id),\n          );\n          block.data.insert(\n            \"parent_id\".to_string(),\n            serde_json::Value::String(ret_view.id.clone()),\n          );\n        } else {\n          tracing::warn!(\"deep_copy_doc_databases: view not found: {}\", block_view_id);\n        }\n      } else {\n        // reference to database\n        if let Some((new_view_id, new_parent_id)) = self\n          .deep_copy_ref_database_in_doc(block_view_id, block_parent_id, &ret_view.id)\n          .await?\n        {\n          block.data.insert(\n            \"view_id\".to_string(),\n            serde_json::Value::String(new_view_id.to_string()),\n          );\n          block.data.insert(\n            \"parent_id\".to_string(),\n            serde_json::Value::String(new_parent_id.to_string()),\n          );\n        } else {\n          tracing::warn!(\"deep_copy_doc_databases: view not found: {}\", block_view_id);\n        }\n      }\n    }\n\n    Ok(())\n  }\n\n  /// deep copy inline database for doc\n  /// returns new view_id\n  /// parent_view_id is assumed to be doc itself\n  async fn deep_copy_inline_database_in_doc(\n    &mut self,\n    view_id: Uuid,\n    doc_view_id: &str,\n  ) -> Result<Option<String>, AppError> {\n    let (metadata, published_blob) = match self.get_published_data_for_view_id(&view_id).await? {\n      Some(published_data) => published_data,\n      None => {\n        tracing::warn!(\"No published collab data found for view_id: {}\", view_id);\n        return Ok(None);\n      },\n    };\n\n    let published_db = deserialize_publish_database_data(&published_blob)?;\n    let mut parent_view = self\n      .deep_copy_database_view(gen_view_id(), published_db, &metadata, &view_id)\n      .await?;\n    let parent_view_id = parent_view.id.clone();\n    if parent_view.parent_view_id.is_empty() {\n      parent_view.parent_view_id = doc_view_id.to_string();\n      let parent_view_id = Uuid::parse_str(&parent_view.id)?;\n      self.views_to_add.insert(parent_view_id, parent_view);\n    }\n    Ok(Some(parent_view_id))\n  }\n\n  /// deep copy referenced database for doc\n  /// returns new (view_id, parent_id)\n  async fn deep_copy_ref_database_in_doc(\n    &mut self,\n    view_id: Uuid,\n    parent_id: Uuid,\n    doc_view_id: &String,\n  ) -> Result<Option<(Uuid, Uuid)>, AppError> {\n    let (metadata, published_blob) = match self.get_published_data_for_view_id(&view_id).await? {\n      Some(published_data) => published_data,\n      None => {\n        tracing::warn!(\"No published collab data found for view_id: {}\", view_id);\n        return Ok(None);\n      },\n    };\n\n    let published_db = serde_json::from_slice::<PublishDatabaseData>(&published_blob)?;\n    let mut parent_view = self\n      .deep_copy_database_view(gen_view_id(), published_db, &metadata, &parent_id)\n      .await?;\n    let parent_view_id: Uuid = parent_view.id.parse()?;\n    if parent_view.parent_view_id.is_empty() {\n      parent_view.parent_view_id = doc_view_id.to_string();\n      self.views_to_add.insert(parent_view_id, parent_view);\n    }\n    let duplicated_view_id = match self.duplicated_db_view.get(&view_id) {\n      Some(v) => *v,\n      None => {\n        let view_info_by_id = view_info_by_view_id(&metadata);\n        let view_info = view_info_by_id.get(&view_id).ok_or_else(|| {\n          AppError::RecordNotFound(format!(\"metadata not found for view: {}\", view_id))\n        })?;\n        let mut new_folder_db_view =\n          self.new_folder_view(view_id, view_info, view_info.layout.clone());\n        new_folder_db_view.parent_view_id = parent_view_id.to_string();\n        let new_folder_db_view_id: Uuid = new_folder_db_view.id.parse()?;\n        self\n          .views_to_add\n          .insert(new_folder_db_view_id, new_folder_db_view);\n        new_folder_db_view_id\n      },\n    };\n    Ok(Some((duplicated_view_id, parent_view_id)))\n  }\n\n  /// Deep copy a published database (does not create folder views)\n  /// checks if database is already published\n  /// attempts to use `new_view_id` for `published_view_id` if not already published\n  /// stores all view_id references in `duplicated_refs`\n  /// returns (published_db_id, new_db_id, is_already_duplicated)\n  async fn deep_copy_database(\n    &mut self,\n    published_db: &PublishDatabaseData,\n    pub_view_id: &Uuid,\n    new_view_id: Uuid,\n  ) -> Result<(Uuid, Uuid, bool), AppError> {\n    // collab of database\n    let client_id = default_client_id();\n    let mut db_collab = collab_from_doc_state(\n      published_db.database_collab.clone(),\n      &Uuid::default(),\n      client_id,\n    )?;\n    let db_body = DatabaseBody::from_collab(\n      &db_collab,\n      Arc::new(NoPersistenceDatabaseCollabService::new(client_id)),\n      None,\n    )\n    .ok_or_else(|| AppError::RecordNotFound(\"no database body found\".to_string()))?;\n    let pub_db_id: Uuid = db_body\n      .get_database_id(&db_collab.context.transact())\n      .parse()?;\n\n    // check if the database is already duplicated\n    if let Some(db_id) = self.duplicated_refs.get(&pub_db_id).cloned().flatten() {\n      return Ok((pub_db_id, db_id, true));\n    }\n    let new_db_id = gen_view_id();\n    self.duplicated_refs.insert(pub_db_id, Some(new_db_id));\n\n    {\n      // assign new id to all views of database.\n      // this will mark the database as duplicated\n      let txn = db_collab.context.transact();\n      let mut db_views = db_body.views.get_all_views(&txn);\n      let mut new_db_view_ids: Vec<_> = Vec::with_capacity(db_views.len());\n      for db_view in db_views.iter_mut() {\n        let db_view_id: Uuid = db_view.id.parse()?;\n        let new_db_view_id = if &db_view_id == pub_view_id {\n          self.duplicated_db_main_view.insert(pub_db_id, new_view_id);\n          new_view_id\n        } else {\n          gen_view_id()\n        };\n        self.duplicated_db_view.insert(db_view_id, new_db_view_id);\n\n        new_db_view_ids.push(new_db_view_id);\n      }\n      // if there is no main view id, use the inline view id\n      if let std::collections::hash_map::Entry::Vacant(e) =\n        self.duplicated_db_main_view.entry(pub_db_id)\n      {\n        e.insert(db_body.get_inline_view_id(&txn).parse()?);\n      };\n\n      // Add this database as linked view\n      self.workspace_databases.insert(new_db_id, new_db_view_ids);\n    }\n\n    // assign new id to all rows of database.\n    // this will mark the rows as duplicated\n    for pub_row_id in published_db.database_row_collabs.keys() {\n      // assign a new id for the row\n      let dup_row_id = Uuid::new_v4();\n      self.duplicated_db_row.insert(*pub_row_id, dup_row_id);\n    }\n\n    {\n      // handle row relations\n      let mut txn = db_collab.context.transact_mut();\n      let all_fields = db_body.fields.get_all_fields(&txn);\n      for mut field in all_fields {\n        for (key, type_option_value) in field.type_options.iter_mut() {\n          if *key == FieldType::Relation.type_id() {\n            if let Some(pub_db_id) = type_option_value.get_mut(\"database_id\") {\n              if let Any::String(pub_db_id_str) = pub_db_id {\n                let pub_db_uuid = Uuid::parse_str(pub_db_id_str)?;\n                if let Some(&pub_rel_db_view) = published_db.database_relations.get(&pub_db_uuid) {\n                  if let Some(_dup_view_id) = self\n                    .deep_copy_view(pub_rel_db_view, self.dest_view_id)\n                    .await?\n                  {\n                    if let Some(dup_db_id) =\n                      self.duplicated_refs.get(&pub_db_uuid).cloned().flatten()\n                    {\n                      *pub_db_id = Any::from(dup_db_id.to_string());\n                      db_body.fields.update_field(&mut txn, &field.id, |f| {\n                        f.set_type_option(\n                          FieldType::Relation.into(),\n                          Some(type_option_value.clone()),\n                        );\n                      });\n                    };\n                  };\n                };\n              }\n            }\n          }\n        }\n      }\n    }\n\n    // duplicate db collab rows\n    for (pub_row_id, row_bin_data) in &published_db.database_row_collabs {\n      let dup_row_id = *self\n        .duplicated_db_row\n        .get(pub_row_id)\n        .ok_or_else(|| AppError::RecordNotFound(format!(\"row not found: {}\", pub_row_id)))?;\n\n      let mut db_row_collab =\n        collab_from_doc_state(row_bin_data.clone(), &dup_row_id, default_client_id())?;\n      let mut db_row_body = DatabaseRowBody::open((*pub_row_id).into(), &mut db_row_collab)\n        .map_err(|e| AppError::Unhandled(e.to_string()))?;\n\n      {\n        let mut txn = db_row_collab.context.transact_mut();\n        // update database_id\n        db_row_body.update(&mut txn, |u| {\n          u.set_database_id(new_db_id.to_string());\n        });\n\n        // get row document id before the id update\n        let pub_row_doc_id = db_row_body\n          .document_id(&txn)\n          .map_err(|e| AppError::Unhandled(e.to_string()))?;\n\n        // updates row id along with meta keys\n        db_row_body\n          .update_id(&mut txn, dup_row_id.into())\n          .map_err(|e| AppError::Unhandled(format!(\"failed to update row id: {:?}\", e)))?;\n\n        // duplicate row document if exists\n        if let Some(pub_row_doc_id) = pub_row_doc_id {\n          let pub_row_doc_id: Uuid = pub_row_doc_id.parse()?;\n          if let Some(row_doc_doc_state) = published_db\n            .database_row_document_collabs\n            .get(&pub_row_doc_id)\n          {\n            match collab_from_doc_state(\n              row_doc_doc_state.to_vec(),\n              &pub_row_doc_id,\n              default_client_id(),\n            ) {\n              Ok(pub_doc_collab) => {\n                let pub_doc =\n                  Document::open(pub_doc_collab).map_err(|e| AppError::Unhandled(e.to_string()))?;\n                let dup_row_doc_id =\n                  meta_id_from_row_id(&dup_row_id, RowMetaKey::DocumentId).parse()?;\n                let mut new_doc_view = Box::pin(self.deep_copy_doc(\n                  pub_row_doc_id,\n                  dup_row_doc_id,\n                  pub_doc,\n                  PublishViewMetaData::default(),\n                ))\n                .await?;\n                new_doc_view.parent_view_id = dup_row_doc_id.to_string(); // orphan folder view\n                self.views_to_add.insert(dup_row_doc_id, new_doc_view);\n              },\n              Err(err) => tracing::error!(\"failed to open row document: {}\", err),\n            };\n          } else {\n            tracing::error!(\"no document found for row: {}\", pub_row_doc_id);\n          };\n        }\n\n        {\n          // \"cells\": Object {\n          //     \"MBaTsr\": Object {\n          //         \"data\": Array [\n          //             String(\"eefb5700-8cf7-411e-9596-f60b9a51916e\"),\n          //             String(\"23d5e054-42c8-4754-ad69-527e4ffc1e46\"),\n          //             // above are published row ids of related database\n          //             // we need to replace them with respective duplicated row ids\n          //         ],\n          //         \"field_type\": Number(10),\n          //         // use this condition to filter out relation cells\n          //     },\n          // },\n          let cells: MapRef = db_row_body\n            .get_data()\n            .get(&txn, ROW_CELLS)\n            .ok_or_else(|| {\n              AppError::RecordNotFound(\"no cells found in database row collab\".to_string())\n            })?\n            .cast()\n            .map_err(|e| AppError::Unhandled(format!(\"not a map: {:?}\", e)))?;\n\n          // collect all cell with field type as relation\n          let mut rel_row_idss = vec![];\n          for (_, out) in cells.iter(&txn) {\n            if let Ok(m) = out.cast::<MapRef>() {\n              if let Some(Out::Any(Any::BigInt(n))) = m.get(&txn, CELL_FIELD_TYPE) {\n                if n == FieldType::Relation as i64 {\n                  match m.get(&txn, CELL_DATA) {\n                    Some(relation_data) => {\n                      if let Ok(arr) = relation_data.cast::<ArrayRef>() {\n                        rel_row_idss.push(arr)\n                      };\n                    },\n                    None => {\n                      tracing::warn!(\"no data found in relation cell, pub_row_id: {}\", pub_row_id)\n                    },\n                  }\n                }\n              }\n            }\n          }\n          // replace all relation cells with duplicated row ids\n          for rel_row_ids in rel_row_idss {\n            let num_refs = rel_row_ids.len(&txn);\n            let mut pub_row_ids = Vec::with_capacity(num_refs as usize);\n            for rel_row_id in rel_row_ids.iter(&txn) {\n              if let Out::Any(Any::String(s)) = rel_row_id {\n                pub_row_ids.push(s);\n              }\n            }\n            rel_row_ids.remove_range(&mut txn, 0, num_refs);\n            for pub_row_id in pub_row_ids {\n              let pub_row_id = Uuid::parse_str(&pub_row_id)?;\n              let dup_row_id = self.duplicated_db_row.get(&pub_row_id).ok_or_else(|| {\n                AppError::RecordNotFound(format!(\"row not found: {}\", pub_row_id))\n              })?;\n              let _ = rel_row_ids.push_back(&mut txn, dup_row_id.to_string());\n            }\n          }\n        }\n      }\n\n      // write new row collab to storage\n      let db_row_ec_bytes = collab_to_bin(db_row_collab, CollabType::DatabaseRow).await?;\n      self\n        .collabs_to_insert\n        .insert(dup_row_id, (CollabType::DatabaseRow, db_row_ec_bytes));\n    }\n\n    // accumulate list of database views (Board, Cal, ...) to be linked to the database\n    {\n      let mut txn = db_collab.context.transact_mut();\n      db_body.root.insert(&mut txn, \"id\", new_db_id.to_string());\n\n      let mut db_views = db_body.views.get_all_views(&txn);\n      for db_view in db_views.iter_mut() {\n        let db_view_id = Uuid::parse_str(&db_view.id)?;\n        let new_db_view_id = self.duplicated_db_view.get(&db_view_id).ok_or_else(|| {\n          AppError::Unhandled(format!(\n            \"view not found in duplicated_db_view: {}\",\n            db_view.id\n          ))\n        })?;\n\n        db_view.id = new_db_view_id.to_string();\n        db_view.database_id = new_db_id.to_string();\n\n        // update all views's row's id\n        for row_order in db_view.row_orders.iter_mut() {\n          let row_order_id = Uuid::parse_str(&row_order.id)?;\n          if let Some(new_id) = self.duplicated_db_row.get(&row_order_id) {\n            row_order.id = (*new_id).into();\n          } else {\n            // skip if row not found\n            tracing::warn!(\"row not found: {}\", row_order.id);\n            continue;\n          }\n        }\n      }\n\n      // update database metas iid\n      db_body\n        .metas\n        .insert(&mut txn, \"iid\", new_view_id.to_string());\n\n      // insert updated views back to db\n      db_body.views.clear(&mut txn);\n      for view in db_views {\n        db_body.views.insert_view(&mut txn, view);\n      }\n    }\n\n    // write database collab to storage\n    let db_encoded_collab = collab_to_bin(db_collab, CollabType::Database).await?;\n    self\n      .collabs_to_insert\n      .insert(new_db_id, (CollabType::Database, db_encoded_collab));\n\n    Ok((pub_db_id, new_db_id, false))\n  }\n\n  /// Deep copy a published database to the destination workspace.\n  /// Returns the Folder view for main view (`new_view_id`) and map from old to new view_id.\n  /// If the database is already duplicated before, does not return the view with `new_view_id`\n  async fn deep_copy_database_view(\n    &mut self,\n    new_view_id: Uuid,\n    published_db: PublishDatabaseData,\n    metadata: &PublishViewMetaData,\n    pub_view_id: &Uuid,\n  ) -> Result<View, AppError> {\n    // flatten nested view info into a map\n    let view_info_by_id = view_info_by_view_id(metadata);\n\n    let (pub_db_id, _dup_db_id, db_alr_duplicated) = self\n      .deep_copy_database(&published_db, pub_view_id, new_view_id)\n      .await?;\n\n    if db_alr_duplicated {\n      let dup_view_id = self\n        .duplicated_db_view\n        .get(pub_view_id)\n        .cloned()\n        .ok_or_else(|| AppError::RecordNotFound(format!(\"view not found: {}\", pub_view_id)))?;\n\n      // db_view_id found but may not have been created due to visibility\n      match self.views_to_add.get(&dup_view_id) {\n        Some(v) => return Ok(v.clone()),\n        None => {\n          let main_view_id = self\n            .duplicated_db_main_view\n            .get(&pub_db_id)\n            .ok_or_else(|| {\n              AppError::RecordNotFound(format!(\"main view not found: {}\", pub_view_id))\n            })?;\n\n          let view_info = view_info_by_id.get(pub_view_id).ok_or_else(|| {\n            AppError::RecordNotFound(format!(\"metadata not found for view: {}\", main_view_id))\n          })?;\n\n          let mut view = self.new_folder_view(dup_view_id, view_info, view_info.layout.clone());\n          let main_view_id = main_view_id.to_string();\n          if main_view_id != view.id {\n            view.parent_view_id = main_view_id;\n          }\n          return Ok(view);\n        },\n      };\n    } else {\n      tracing::warn!(\"database not duplicated: {}\", pub_view_id);\n    }\n\n    // create a new view to be returned to the caller\n    // view_id is the main view of the database\n    // create the main view\n    let main_view_id = self\n      .duplicated_db_main_view\n      .get(&pub_db_id)\n      .ok_or_else(|| AppError::RecordNotFound(format!(\"main view not found: {}\", pub_view_id)))?;\n\n    let main_view_info = view_info_by_id.get(pub_view_id).ok_or_else(|| {\n      AppError::RecordNotFound(format!(\"metadata not found for view: {}\", pub_view_id))\n    })?;\n    let main_folder_view =\n      self.new_folder_view(*main_view_id, main_view_info, main_view_info.layout.clone());\n\n    // create other visible view which are child to the main view\n    for vis_view_id in published_db.visible_database_view_ids {\n      if &vis_view_id == pub_view_id {\n        // skip main view\n        continue;\n      }\n\n      let child_view_id = self\n        .duplicated_db_view\n        .get(&vis_view_id)\n        .ok_or_else(|| AppError::RecordNotFound(format!(\"view not found: {}\", vis_view_id)))?;\n\n      let child_view_info = view_info_by_id.get(&vis_view_id).ok_or_else(|| {\n        AppError::RecordNotFound(format!(\"metadata not found for view: {}\", vis_view_id))\n      })?;\n\n      let mut child_folder_view = self.new_folder_view(\n        *child_view_id,\n        view_info_by_id.get(&vis_view_id).ok_or_else(|| {\n          AppError::RecordNotFound(format!(\"metadata not found for view: {}\", vis_view_id))\n        })?,\n        child_view_info.layout.clone(),\n      );\n      child_folder_view.parent_view_id = main_view_id.to_string();\n      self\n        .views_to_add\n        .insert(child_folder_view.id.parse()?, child_folder_view);\n    }\n\n    Ok(main_folder_view)\n  }\n\n  /// creates a new folder view without parent_view_id set\n  fn new_folder_view(\n    &self,\n    new_view_id: Uuid,\n    view_info: &PublishViewInfo,\n    layout: ViewLayout,\n  ) -> View {\n    View {\n      id: new_view_id.to_string(),\n      parent_view_id: \"\".to_string(), // to be filled by caller\n      name: view_info.name.clone(),\n      children: RepeatedViewIdentifier { items: vec![] }, // fill in while iterating children\n      created_at: self.ts_now,\n      is_favorite: false,\n      layout: to_folder_view_layout(layout),\n      icon: view_info.icon.clone().map(to_folder_view_icon),\n      created_by: Some(self.duplicator_uid),\n      last_edited_time: self.ts_now,\n      last_edited_by: Some(self.duplicator_uid),\n      is_locked: None,\n      extra: view_info.extra.clone(),\n    }\n  }\n\n  async fn get_published_data_for_view_id(\n    &self,\n    view_id: &uuid::Uuid,\n  ) -> Result<Option<(PublishViewMetaData, Vec<u8>)>, AppError> {\n    let result = select_published_metadata_for_view_id(&self.pg_pool, view_id).await?;\n    match result {\n      Some((workspace_id, js_val)) => {\n        let metadata = serde_json::from_value(js_val)?;\n        let object_key = format!(\"published-collab/{}/{}\", workspace_id, view_id);\n        match self.bucket_client.get_blob(&object_key).await {\n          Ok(resp) => Ok(Some((metadata, resp.to_blob()))),\n          Err(_) => match select_published_data_for_view_id(&self.pg_pool, view_id).await? {\n            Some((js_val, blob)) => {\n              let metadata = serde_json::from_value(js_val)?;\n              Ok(Some((metadata, blob)))\n            },\n            None => Ok(None),\n          },\n        }\n      },\n      None => Ok(None),\n    }\n  }\n}\n\nfn view_info_by_view_id(meta: &PublishViewMetaData) -> HashMap<Uuid, PublishViewInfo> {\n  let mut acc = HashMap::new();\n  acc.insert(meta.view.view_id.parse().unwrap(), meta.view.clone());\n  add_to_view_info(&mut acc, &meta.child_views);\n  add_to_view_info(&mut acc, &meta.ancestor_views);\n  acc\n}\n\nfn add_to_view_info(acc: &mut HashMap<Uuid, PublishViewInfo>, view_infos: &[PublishViewInfo]) {\n  for view_info in view_infos {\n    acc.insert(view_info.view_id.parse().unwrap(), view_info.clone());\n    if let Some(child_views) = &view_info.child_views {\n      add_to_view_info(acc, child_views);\n    }\n  }\n}\n"
  },
  {
    "path": "src/biz/workspace/quick_note.rs",
    "content": "use app_error::AppError;\nuse database::quick_note::{\n  delete_quick_note_by_id, insert_new_quick_note, select_quick_notes_with_one_more_than_limit,\n  update_quick_note_by_id,\n};\nuse serde_json::json;\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\nuse database_entity::dto::{QuickNote, QuickNotes};\n\npub async fn create_quick_note(\n  pg_pool: &PgPool,\n  uid: i64,\n  workspace_id: Uuid,\n  data: Option<&serde_json::Value>,\n) -> Result<QuickNote, AppError> {\n  let default_data = json!([\n    {\n      \"type\": \"paragraph\",\n      \"delta\": {\n        \"insert\": \"\",\n      },\n    }\n  ]);\n  let new_data = data.unwrap_or(&default_data);\n  let quick_note = insert_new_quick_note(pg_pool, workspace_id, uid, new_data).await?;\n  Ok(quick_note)\n}\n\npub async fn update_quick_note(\n  pg_pool: &PgPool,\n  quick_note_id: Uuid,\n  data: &serde_json::Value,\n) -> Result<(), AppError> {\n  update_quick_note_by_id(pg_pool, quick_note_id, data).await\n}\n\npub async fn delete_quick_note(pg_pool: &PgPool, quick_note_id: Uuid) -> Result<(), AppError> {\n  delete_quick_note_by_id(pg_pool, quick_note_id).await\n}\n\npub async fn list_quick_notes(\n  pg_pool: &PgPool,\n  uid: i64,\n  workspace_id: Uuid,\n  search_term: Option<String>,\n  offset: Option<i32>,\n  limit: Option<i32>,\n) -> Result<QuickNotes, AppError> {\n  let mut quick_notes_with_one_more_than_limit = select_quick_notes_with_one_more_than_limit(\n    pg_pool,\n    workspace_id,\n    uid,\n    search_term,\n    offset,\n    limit,\n  )\n  .await?;\n  let has_more = if let Some(limit) = limit {\n    quick_notes_with_one_more_than_limit.len() as i32 > limit\n  } else {\n    false\n  };\n  if let Some(limit) = limit {\n    quick_notes_with_one_more_than_limit.truncate(limit as usize);\n  }\n  let quick_notes = quick_notes_with_one_more_than_limit;\n\n  Ok(QuickNotes {\n    quick_notes,\n    has_more,\n  })\n}\n"
  },
  {
    "path": "src/config/config.rs",
    "content": "use std::fmt::Display;\nuse std::str::FromStr;\n\nuse anyhow::{anyhow, Context};\nuse async_openai::config::{AzureConfig, OpenAIConfig};\nuse indexer::vector::embedder::get_open_ai_config;\nuse infra::env_util::{get_env_var, get_env_var_opt};\nuse mailer::config::MailerSetting;\nuse secrecy::{ExposeSecret, Secret};\nuse semver::Version;\nuse serde::Deserialize;\nuse sqlx::postgres::{PgConnectOptions, PgSslMode};\n\n#[derive(Clone, Debug)]\npub struct Config {\n  pub app_env: Environment,\n  pub access_control: AccessControlSetting,\n  pub db_settings: DatabaseSetting,\n  pub gotrue: GoTrueSetting,\n  pub application: ApplicationSetting,\n  pub websocket: WebsocketSetting,\n  pub redis_uri: Secret<String>,\n  pub redis_worker_count: usize,\n  pub s3: S3Setting,\n  pub appflowy_ai: AppFlowyAISetting,\n  pub collab: CollabSetting,\n  pub published_collab: PublishedCollabSetting,\n  pub mailer: MailerSetting,\n  pub apple_oauth: AppleOAuthSetting,\n  pub appflowy_web_url: String,\n  pub notification: NotificationSetting,\n  pub open_ai_config: Option<OpenAIConfig>,\n  pub azure_ai_config: Option<AzureConfig>,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\n\npub struct AccessControlSetting {\n  pub is_enabled: bool,\n  pub enable_workspace_access_control: bool,\n  pub enable_collab_access_control: bool,\n  pub enable_realtime_access_control: bool,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct AppleOAuthSetting {\n  pub client_id: String,\n  pub client_secret: Secret<String>,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct CasbinSetting {\n  pub pool_size: u32,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct S3Setting {\n  pub create_bucket: bool,\n  pub use_minio: bool,\n  pub minio_url: String,\n  pub access_key: String,\n  pub secret_key: Secret<String>,\n  pub bucket: String,\n  pub region: String,\n  pub presigned_url_endpoint: Option<String>,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct GoTrueSetting {\n  pub base_url: String,\n  pub jwt_secret: Secret<String>,\n  pub service_role: String,\n}\n\n#[derive(serde::Deserialize, Clone, Debug)]\npub struct AppFlowyAISetting {\n  pub port: Secret<String>,\n  pub host: Secret<String>,\n}\n\nimpl AppFlowyAISetting {\n  pub fn url(&self) -> String {\n    format!(\n      \"http://{}:{}\",\n      self.host.expose_secret(),\n      self.port.expose_secret()\n    )\n  }\n}\n\n// We are using 127.0.0.1 as our host in address, we are instructing our\n// application to only accept connections coming from the same machine. However,\n// request from the hose machine which is not seen as local by our Docker image.\n//\n// Using 0.0.0.0 as host to instruct our application to accept connections from\n// any network interface. So using 127.0.0.1 for our local development and set\n// it to 0.0.0.0 in our Docker images.\n//\n#[derive(Clone, Debug)]\npub struct ApplicationSetting {\n  pub port: u16,\n  pub host: String,\n}\n\n#[derive(Clone, Debug)]\npub struct DatabaseSetting {\n  pub pg_conn_opts: PgConnectOptions,\n  pub require_ssl: bool,\n  /// PostgreSQL has a maximum of 115 connections to the database, 15 connections are reserved to\n  /// the super user to maintain the integrity of the PostgreSQL database, and 100 PostgreSQL\n  /// connections are reserved for system applications.\n  /// When we exceed the limit of the database connection, then it shows an error message.\n  pub max_connections: u32,\n}\n\nimpl Display for DatabaseSetting {\n  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n    let masked_pg_conn_opts = self.pg_conn_opts.clone().password(\"********\");\n    write!(\n      f,\n      \"DatabaseSetting {{ pg_conn_opts: {:?}, require_ssl: {}, max_connections: {} }}\",\n      masked_pg_conn_opts, self.require_ssl, self.max_connections\n    )\n  }\n}\n\nimpl DatabaseSetting {\n  pub fn pg_connect_options(&self) -> PgConnectOptions {\n    let ssl_mode = if self.require_ssl {\n      PgSslMode::Require\n    } else {\n      PgSslMode::Prefer\n    };\n    let options = self.pg_conn_opts.clone();\n    options.ssl_mode(ssl_mode)\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct CollabSetting {\n  pub group_persistence_interval_secs: u64,\n  pub group_prune_grace_period_secs: u64,\n  pub edit_state_max_count: u32,\n  pub edit_state_max_secs: i64,\n  pub s3_collab_threshold: u64,\n}\n\n#[derive(Clone, Debug)]\npub enum PublishedCollabStorageBackend {\n  Postgres,\n  S3WithPostgresBackup,\n}\n\n#[derive(Clone, Debug)]\npub struct PublishedCollabSetting {\n  pub storage_backend: PublishedCollabStorageBackend,\n}\n\nimpl TryFrom<&str> for PublishedCollabStorageBackend {\n  type Error = anyhow::Error;\n\n  fn try_from(value: &str) -> Result<Self, Self::Error> {\n    match value {\n      \"postgres\" => Ok(PublishedCollabStorageBackend::Postgres),\n      \"s3_with_postgres_backup\" => Ok(PublishedCollabStorageBackend::S3WithPostgresBackup),\n      _ => Err(anyhow::anyhow!(\"Invalid PublishedCollabStorageBackend\")),\n    }\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct NotificationSetting {\n  pub enable_email_notification: bool,\n  pub email_notification_interval_secs: u64,\n  pub email_notification_grace_period_secs: u64,\n}\n\n// Default values favor local development.\npub fn get_configuration() -> Result<Config, anyhow::Error> {\n  let (open_ai_config, azure_ai_config) = get_open_ai_config();\n  let config = Config {\n    app_env: get_env_var(\"APPFLOWY_ENVIRONMENT\", \"local\")\n      .parse()\n      .context(\"fail to get APPFLOWY_ENVIRONMENT\")?,\n    access_control: AccessControlSetting {\n      is_enabled: get_env_var(\"APPFLOWY_ACCESS_CONTROL\", \"false\")\n        .parse()\n        .context(\"fail to get APPFLOWY_ACCESS_CONTROL\")?,\n      enable_workspace_access_control: get_env_var(\"APPFLOWY_ACCESS_CONTROL_WORKSPACE\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_ACCESS_CONTROL_WORKSPACE\")?,\n      enable_collab_access_control: get_env_var(\"APPFLOWY_ACCESS_CONTROL_COLLAB\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_ACCESS_CONTROL_COLLAB\")?,\n      enable_realtime_access_control: get_env_var(\"APPFLOWY_ACCESS_CONTROL_REALTIME\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_ACCESS_CONTROL_REALTIME\")?,\n    },\n    db_settings: DatabaseSetting {\n      pg_conn_opts: PgConnectOptions::from_str(&get_env_var(\n        \"APPFLOWY_DATABASE_URL\",\n        \"postgres://postgres:password@localhost:5432/postgres\",\n      ))?,\n      require_ssl: get_env_var(\"APPFLOWY_DATABASE_REQUIRE_SSL\", \"false\")\n        .parse()\n        .context(\"fail to get APPFLOWY_DATABASE_REQUIRE_SSL\")?,\n      max_connections: get_env_var(\"APPFLOWY_DATABASE_MAX_CONNECTIONS\", \"40\")\n        .parse()\n        .context(\"fail to get APPFLOWY_DATABASE_MAX_CONNECTIONS\")?,\n    },\n    gotrue: GoTrueSetting {\n      base_url: get_env_var(\"APPFLOWY_GOTRUE_BASE_URL\", \"http://localhost:9999\"),\n      jwt_secret: get_env_var(\"APPFLOWY_GOTRUE_JWT_SECRET\", \"hello456\").into(),\n      service_role: get_env_var(\"APPFLOWY_GOTRUE_SERVICE_ROLE\", \"service_role\"),\n    },\n    application: ApplicationSetting {\n      port: get_env_var(\"APPFLOWY_APPLICATION_PORT\", \"8000\").parse()?,\n      host: get_env_var(\"APPFLOWY_APPLICATION_HOST\", \"[::]\"),\n    },\n    websocket: WebsocketSetting {\n      heartbeat_interval: get_env_var(\"APPFLOWY_WEBSOCKET_HEARTBEAT_INTERVAL\", \"6\").parse()?,\n      client_timeout: get_env_var(\"APPFLOWY_WEBSOCKET_CLIENT_TIMEOUT\", \"60\").parse()?,\n      min_client_version: get_env_var(\"APPFLOWY_WEBSOCKET_CLIENT_MIN_VERSION\", \"0.5.0\").parse()?,\n    },\n    redis_uri: get_env_var(\"APPFLOWY_REDIS_URI\", \"redis://localhost:6379\").into(),\n    redis_worker_count: get_env_var(\"APPFLOWY_REDIS_WORKERS\", \"60\").parse()?,\n    s3: S3Setting {\n      create_bucket: get_env_var(\"APPFLOWY_S3_CREATE_BUCKET\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_S3_CREATE_BUCKET\")?,\n      use_minio: get_env_var(\"APPFLOWY_S3_USE_MINIO\", \"true\")\n        .parse()\n        .context(\"fail to get APPFLOWY_S3_USE_MINIO\")?,\n      minio_url: get_env_var(\"APPFLOWY_S3_MINIO_URL\", \"http://localhost:9000\"),\n      access_key: get_env_var(\"APPFLOWY_S3_ACCESS_KEY\", \"minioadmin\"),\n      secret_key: get_env_var(\"APPFLOWY_S3_SECRET_KEY\", \"minioadmin\").into(),\n      bucket: get_env_var(\"APPFLOWY_S3_BUCKET\", \"appflowy\"),\n      region: get_env_var(\"APPFLOWY_S3_REGION\", \"\"),\n      presigned_url_endpoint: get_env_var_opt(\"APPFLOWY_S3_PRESIGNED_URL_ENDPOINT\"),\n    },\n    appflowy_ai: AppFlowyAISetting {\n      port: get_env_var(\"AI_SERVER_PORT\", \"5001\").into(),\n      host: get_env_var(\"AI_SERVER_HOST\", \"localhost\").into(),\n    },\n    collab: CollabSetting {\n      group_persistence_interval_secs: get_env_var(\n        \"APPFLOWY_COLLAB_GROUP_PERSISTENCE_INTERVAL\",\n        \"60\",\n      )\n      .parse()?,\n      group_prune_grace_period_secs: get_env_var(\"APPFLOWY_COLLAB_GROUP_GRACE_PERIOD_SECS\", \"60\")\n        .parse()?,\n      edit_state_max_count: get_env_var(\"APPFLOWY_COLLAB_EDIT_STATE_MAX_COUNT\", \"100\").parse()?,\n      edit_state_max_secs: get_env_var(\"APPFLOWY_COLLAB_EDIT_STATE_MAX_SECS\", \"60\").parse()?,\n      s3_collab_threshold: get_env_var(\"APPFLOWY_COLLAB_S3_THRESHOLD\", \"8000\").parse()?,\n    },\n    published_collab: PublishedCollabSetting {\n      storage_backend: get_env_var(\"APPFLOWY_PUBLISHED_COLLAB_STORAGE_BACKEND\", \"postgres\")\n        .as_str()\n        .try_into()?,\n    },\n    mailer: MailerSetting {\n      smtp_host: get_env_var(\"APPFLOWY_MAILER_SMTP_HOST\", \"smtp.gmail.com\"),\n      smtp_port: get_env_var(\"APPFLOWY_MAILER_SMTP_PORT\", \"465\").parse()?,\n      smtp_username: get_env_var(\"APPFLOWY_MAILER_SMTP_USERNAME\", \"sender@example.com\"),\n      smtp_email: get_env_var(\"APPFLOWY_MAILER_SMTP_EMAIL\", \"sender@example.com\"),\n      smtp_password: get_env_var(\"APPFLOWY_MAILER_SMTP_PASSWORD\", \"password\").into(),\n      smtp_tls_kind: get_env_var(\"APPFLOWY_MAILER_SMTP_TLS_KIND\", \"wrapper\"),\n    },\n    apple_oauth: AppleOAuthSetting {\n      client_id: get_env_var(\"APPFLOWY_APPLE_OAUTH_CLIENT_ID\", \"\"),\n      client_secret: get_env_var(\"APPFLOWY_APPLE_OAUTH_CLIENT_SECRET\", \"\").into(),\n    },\n    appflowy_web_url: get_env_var_opt(\"APPFLOWY_WEB_URL\")\n      .ok_or(anyhow!(\"APPFLOWY_WEB_URL has not been set\"))?,\n    notification: NotificationSetting {\n      enable_email_notification: get_env_var(\"APPFLOWY_NOTIFICATION_ENABLE_EMAIL\", \"false\")\n        .parse()?,\n      email_notification_interval_secs: get_env_var(\n        \"APPFLOWY_NOTIFICATION_EMAIL_INTERVAL_SECS\",\n        \"900\",\n      )\n      .parse()?,\n      email_notification_grace_period_secs: get_env_var(\n        \"APPFLOWY_NOTIFICATION_EMAIL_GRACE_PERIOD_SECS\",\n        \"450\",\n      )\n      .parse()?,\n    },\n    open_ai_config,\n    azure_ai_config,\n  };\n  Ok(config)\n}\n\n/// The possible runtime environment for our application.\n#[derive(Clone, Debug, Deserialize)]\npub enum Environment {\n  Local,\n  Production,\n}\n\nimpl Environment {\n  pub fn as_str(&self) -> &'static str {\n    match self {\n      Environment::Local => \"local\",\n      Environment::Production => \"production\",\n    }\n  }\n}\n\nimpl FromStr for Environment {\n  type Err = anyhow::Error;\n\n  fn from_str(s: &str) -> Result<Self, Self::Err> {\n    match s.to_lowercase().as_str() {\n      \"local\" => Ok(Self::Local),\n      \"production\" => Ok(Self::Production),\n      other => anyhow::bail!(\n        \"{} is not a supported environment. Use either `local` or `production`.\",\n        other\n      ),\n    }\n  }\n}\n\n#[derive(Clone, Debug)]\npub struct WebsocketSetting {\n  pub heartbeat_interval: u8,\n  pub client_timeout: u8,\n  pub min_client_version: Version,\n}\n"
  },
  {
    "path": "src/config/mod.rs",
    "content": "#![allow(clippy::module_inception)]\npub mod config;\n"
  },
  {
    "path": "src/domain/compression.rs",
    "content": "use app_error::AppError;\nuse brotli::{CompressorReader, Decompressor};\nuse std::io::Read;\n\npub const X_COMPRESSION_TYPE: &str = \"X-Compression-Type\";\n\npub const X_COMPRESSION_BUFFER_SIZE: &str = \"X-Compression-Buffer-Size\";\npub enum CompressionType {\n  Brotli { buffer_size: usize },\n}\n\nimpl CompressionType {\n  pub fn buffer_size(&self) -> usize {\n    match self {\n      CompressionType::Brotli { buffer_size } => *buffer_size,\n    }\n  }\n}\n\npub async fn compress(\n  data: Vec<u8>,\n  quality: u32,\n  buffer_size: usize,\n) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || {\n    let mut compressor = CompressorReader::new(&*data, buffer_size, quality, 22);\n    let mut compressed_data = Vec::new();\n    compressor\n      .read_to_end(&mut compressed_data)\n      .map_err(|err| AppError::InvalidRequest(format!(\"Failed to compress data: {}\", err)))?;\n    Ok(compressed_data)\n  })\n  .await\n  .map_err(AppError::from)?\n}\n\npub fn decompress(data: Vec<u8>, buffer_size: usize) -> Result<Vec<u8>, AppError> {\n  let mut decompressor = Decompressor::new(&*data, buffer_size);\n  let mut decompressed_data = Vec::new();\n  decompressor\n    .read_to_end(&mut decompressed_data)\n    .map_err(|err| {\n      AppError::InvalidRequest(format!(\"Failed to decompress data:{} {}\", data.len(), err))\n    })?;\n  Ok(decompressed_data)\n}\n\npub async fn blocking_decompress(data: Vec<u8>, buffer_size: usize) -> Result<Vec<u8>, AppError> {\n  tokio::task::spawn_blocking(move || decompress(data, buffer_size))\n    .await\n    .map_err(AppError::from)?\n}\n"
  },
  {
    "path": "src/domain/mod.rs",
    "content": "pub mod compression;\n"
  },
  {
    "path": "src/lib.rs",
    "content": "pub mod api;\npub mod application;\npub mod biz;\npub mod config;\npub mod domain;\npub mod mailer;\npub mod middleware;\npub mod state;\npub mod telemetry;\n"
  },
  {
    "path": "src/mailer.rs",
    "content": "use mailer::sender::Mailer;\nuse std::collections::HashMap;\n\npub const WORKSPACE_INVITE_TEMPLATE_NAME: &str = \"workspace_invite\";\npub const WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME: &str = \"workspace_access_request\";\npub const WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME: &str =\n  \"workspace_access_request_approved_notification\";\npub const PAGE_MENTION_NOTIFICATION_TEMPLATE_NAME: &str = \"page_mention_notification\";\n\n#[derive(Clone)]\npub struct AFCloudMailer(Mailer);\nimpl AFCloudMailer {\n  pub async fn new(mut mailer: Mailer) -> Result<Self, anyhow::Error> {\n    register_mailer(&mut mailer).await?;\n    Ok(Self(mailer))\n  }\n\n  pub async fn send_workspace_invite(\n    &self,\n    email: &str,\n    param: WorkspaceInviteMailerParam,\n  ) -> Result<(), anyhow::Error> {\n    let subject = format!(\n      \"{} invited you to {} in AppFlowy\",\n      param.username, param.workspace_name\n    );\n    self\n      .0\n      .send_email_template(\n        Some(param.username.clone()),\n        email,\n        WORKSPACE_INVITE_TEMPLATE_NAME,\n        param,\n        &subject,\n      )\n      .await\n      .map(|_| tracing::info!(\"Sent workspace invite email to {}\", email))\n      .map_err(|err| {\n        tracing::error!(\n          \"Failed to send workspace invite email to {}: {}\",\n          email,\n          err\n        );\n        err\n      })\n  }\n\n  pub async fn send_workspace_access_request(\n    &self,\n    recipient_name: &str,\n    email: &str,\n    param: WorkspaceAccessRequestMailerParam,\n  ) -> Result<(), anyhow::Error> {\n    let subject = format!(\n      \"{} requested access to {} in AppFlowy\",\n      param.username, param.workspace_name\n    );\n    self\n      .0\n      .send_email_template(\n        Some(recipient_name.to_string()),\n        email,\n        WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,\n        param,\n        &subject,\n      )\n      .await\n  }\n\n  pub async fn send_workspace_access_request_approval_notification(\n    &self,\n    recipient_name: &str,\n    email: &str,\n    param: WorkspaceAccessRequestApprovedMailerParam,\n  ) -> Result<(), anyhow::Error> {\n    let subject = \"Notification: Workspace access request approved\";\n    self\n      .0\n      .send_email_template(\n        Some(recipient_name.to_string()),\n        email,\n        WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME,\n        param,\n        subject,\n      )\n      .await\n  }\n\n  pub async fn send_page_mention_notification(\n    &self,\n    recipient_name: &str,\n    email: &str,\n    param: &PageMentionNotificationMailerParam,\n  ) -> Result<(), anyhow::Error> {\n    let subject = format!(\n      \"{} has mentioned you in {} in AppFlowy\",\n      param.mentioner_name, param.mentioned_page_name\n    );\n    self\n      .0\n      .send_email_template(\n        Some(recipient_name.to_string()),\n        email,\n        PAGE_MENTION_NOTIFICATION_TEMPLATE_NAME,\n        param,\n        &subject,\n      )\n      .await\n  }\n}\n\nasync fn register_mailer(mailer: &mut Mailer) -> Result<(), anyhow::Error> {\n  let workspace_invite_template =\n    include_str!(\"../assets/mailer_templates/build_production/workspace_invitation.html\");\n  let access_request_template =\n    include_str!(\"../assets/mailer_templates/build_production/access_request.html\");\n  let access_request_approved_notification_template = include_str!(\n    \"../assets/mailer_templates/build_production/access_request_approved_notification.html\"\n  );\n  let page_mention_notification_template =\n    include_str!(\"../assets/mailer_templates/build_production/page_mention_notification.html\");\n  let template_strings = HashMap::from([\n    (WORKSPACE_INVITE_TEMPLATE_NAME, workspace_invite_template),\n    (\n      WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,\n      access_request_template,\n    ),\n    (\n      WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME,\n      access_request_approved_notification_template,\n    ),\n    (\n      PAGE_MENTION_NOTIFICATION_TEMPLATE_NAME,\n      page_mention_notification_template,\n    ),\n  ]);\n\n  for (template_name, template_string) in template_strings {\n    mailer\n      .register_template(template_name, template_string)\n      .await\n      .map_err(|err| anyhow::anyhow!(format!(\"Failed to register handlebars template: {}\", err)))?;\n  }\n\n  Ok(())\n}\n\n#[derive(serde::Serialize)]\npub struct WorkspaceInviteMailerParam {\n  pub user_icon_url: String,\n  pub username: String, // Inviter\n  pub workspace_name: String,\n  pub workspace_icon_url: String,\n  pub workspace_member_count: String,\n  pub accept_url: String,\n}\n\n#[derive(serde::Serialize)]\npub struct WorkspaceAccessRequestMailerParam {\n  pub user_icon_url: String,\n  pub username: String,\n  pub workspace_name: String,\n  pub workspace_icon_url: String,\n  pub workspace_member_count: i64,\n  pub approve_url: String,\n}\n\n#[derive(serde::Serialize)]\npub struct WorkspaceAccessRequestApprovedMailerParam {\n  pub workspace_name: String,\n  pub workspace_icon_url: String,\n  pub workspace_member_count: i64,\n  pub launch_workspace_url: String,\n}\n\n#[derive(serde::Serialize)]\npub struct PageMentionNotificationMailerParam {\n  pub workspace_name: String,\n  pub mentioned_page_name: String,\n  pub mentioner_icon_url: String,\n  pub mentioner_name: String,\n  pub mentioned_page_url: String,\n  pub mentioned_at: String,\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "use appflowy_cloud::application::{init_state, Application};\nuse appflowy_cloud::config::config::get_configuration;\nuse appflowy_cloud::telemetry::init_subscriber;\n\n#[actix_web::main]\nasync fn main() -> anyhow::Result<()> {\n  let level = std::env::var(\"RUST_LOG\").unwrap_or(\"info\".to_string());\n  println!(\"AppFlowy Cloud with RUST_LOG={}\", level);\n\n  // Load environment variables from .env file\n  dotenvy::dotenv().ok();\n\n  let conf =\n    get_configuration().map_err(|e| anyhow::anyhow!(\"Failed to read configuration: {}\", e))?;\n\n  init_subscriber(&conf.app_env);\n\n  let state = init_state(&conf)\n    .await\n    .map_err(|e| anyhow::anyhow!(\"Failed to initialize application state: {}\", e))?;\n  let application = Application::build(conf, state).await?;\n  application.run_until_stopped().await?;\n\n  Ok(())\n}\n"
  },
  {
    "path": "src/middleware/metrics_mw.rs",
    "content": "use actix_service::{forward_ready, Service, Transform};\nuse actix_web::dev::{ServiceRequest, ServiceResponse};\nuse actix_web::web::Data;\nuse actix_web::Error;\nuse futures_util::future::LocalBoxFuture;\nuse std::future::{ready, Ready};\nuse std::sync::Arc;\n\nuse super::request_id::get_request_id;\nuse crate::api::metrics::RequestMetrics;\n\npub struct MetricsMiddleware;\n\nimpl<S, B> Transform<S, ServiceRequest> for MetricsMiddleware\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = Error;\n  type Transform = MetricsMiddlewareService<S>;\n  type InitError = ();\n  type Future = Ready<Result<Self::Transform, Self::InitError>>;\n\n  fn new_transform(&self, service: S) -> Self::Future {\n    ready(Ok(MetricsMiddlewareService { service }))\n  }\n}\n\npub struct MetricsMiddlewareService<S> {\n  service: S,\n}\n\nimpl<S, B> Service<ServiceRequest> for MetricsMiddlewareService<S>\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = Error;\n  type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;\n\n  forward_ready!(service);\n\n  fn call(&self, req: ServiceRequest) -> Self::Future {\n    // Get the metrics from the app_data\n    let metrics = match req.app_data::<Data<Arc<RequestMetrics>>>() {\n      Some(m) => m.clone(),\n      None => {\n        tracing::error!(\"Failed to get metrics from app_data\");\n        return Box::pin(self.service.call(req));\n      },\n    };\n\n    let request_id = get_request_id(&req);\n    let endpoint = req.match_pattern();\n    let method = req.method().to_string();\n\n    // Call the next service\n    let res = self.service.call(req);\n    Box::pin(async move {\n      let start = std::time::Instant::now();\n      let res = res.await?;\n      let end = std::time::Instant::now();\n      let duration = end.duration_since(start);\n      let status = res.status();\n      if let Some(endpoint) = endpoint {\n        metrics.record_request(\n          request_id,\n          endpoint,\n          method,\n          duration.as_millis() as u64,\n          status.into(),\n        );\n      }\n      Ok(res)\n    })\n  }\n}\n"
  },
  {
    "path": "src/middleware/mod.rs",
    "content": "pub mod metrics_mw;\npub mod request_id;\n"
  },
  {
    "path": "src/middleware/request_id.rs",
    "content": "use actix_http::header::{HeaderName, HeaderValue};\nuse std::future::{ready, Ready};\nuse tracing::{span, Instrument, Level};\n\nuse crate::api::util::{client_version_from_headers, device_id_from_headers};\nuse actix_service::{forward_ready, Service, Transform};\nuse actix_web::dev::{ServiceRequest, ServiceResponse};\nuse futures_util::future::LocalBoxFuture;\n\nconst X_REQUEST_ID: &str = \"x-request-id\";\npub struct RequestIdMiddleware;\n\nimpl<S, B> Transform<S, ServiceRequest> for RequestIdMiddleware\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = actix_web::Error;\n  type Transform = RequestIdMiddlewareService<S>;\n  type InitError = ();\n  type Future = Ready<Result<Self::Transform, Self::InitError>>;\n\n  fn new_transform(&self, service: S) -> Self::Future {\n    ready(Ok(RequestIdMiddlewareService { service }))\n  }\n}\n\npub struct RequestIdMiddlewareService<S> {\n  service: S,\n}\n\nimpl<S, B> Service<ServiceRequest> for RequestIdMiddlewareService<S>\nwhere\n  S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error>,\n  S::Future: 'static,\n  B: 'static,\n{\n  type Response = ServiceResponse<B>;\n  type Error = actix_web::Error;\n  type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;\n\n  forward_ready!(service);\n\n  fn call(&self, mut req: ServiceRequest) -> Self::Future {\n    // Skip generate request id for metrics requests\n    if skip_request_id(&req) {\n      let fut = self.service.call(req);\n      Box::pin(fut)\n    } else {\n      let request_id = get_request_id(&req).unwrap_or_else(|| {\n        let request_id = uuid::Uuid::new_v4().to_string();\n        if let Ok(header_value) = HeaderValue::from_str(&request_id) {\n          req\n            .headers_mut()\n            .insert(HeaderName::from_static(X_REQUEST_ID), header_value);\n        }\n        request_id\n      });\n\n      let client_info = get_client_info(&req);\n      let span = span!(Level::INFO, \"request\",\n        request_id = %request_id,\n        path = %req.match_pattern().unwrap_or_default(),\n        method = %req.method(),\n        client_version = client_info.client_version,\n        device_id = client_info.device_id,\n        payload_size = client_info.payload_size\n      );\n\n      let fut = self.service.call(req);\n      Box::pin(async move {\n        let mut res = fut.instrument(span).await?;\n\n        // Insert the request id to the response header\n        if let Ok(header_value) = HeaderValue::from_str(&request_id) {\n          res\n            .headers_mut()\n            .insert(HeaderName::from_static(X_REQUEST_ID), header_value);\n        }\n        Ok(res)\n      })\n    }\n  }\n}\n\npub fn get_request_id(req: &ServiceRequest) -> Option<String> {\n  match req.headers().get(HeaderName::from_static(X_REQUEST_ID)) {\n    Some(h) => match h.to_str() {\n      Ok(s) => Some(s.to_owned()),\n      Err(e) => {\n        tracing::error!(\"Failed to get request id from header: {}\", e);\n        None\n      },\n    },\n    None => None,\n  }\n}\n\n#[inline]\nfn get_client_info(req: &ServiceRequest) -> ClientInfo {\n  let payload_size = req\n    .headers()\n    .get(\"content-length\")\n    .and_then(|val| val.to_str().ok())\n    .and_then(|val| val.parse::<usize>().ok())\n    .unwrap_or_default();\n\n  let client_version = client_version_from_headers(req.headers()).ok();\n  let device_id = device_id_from_headers(req.headers()).ok();\n\n  ClientInfo {\n    payload_size,\n    client_version,\n    device_id,\n  }\n}\n\nstruct ClientInfo<'a> {\n  payload_size: usize,\n  client_version: Option<&'a str>,\n  device_id: Option<&'a str>,\n}\n\n#[inline]\nfn skip_request_id(req: &ServiceRequest) -> bool {\n  if req.path().starts_with(\"/metrics\") {\n    return true;\n  }\n\n  if req.path().starts_with(\"/ws\") {\n    return true;\n  }\n\n  false\n}\n"
  },
  {
    "path": "src/state.rs",
    "content": "use access_control::collab::{CollabAccessControl, RealtimeAccessControl};\nuse access_control::workspace::WorkspaceAccessControl;\nuse actix::Addr;\nuse anyhow::anyhow;\nuse dashmap::DashMap;\nuse gotrue_entity::gotrue_jwt::GoTrueServiceRoleClaims;\nuse secrecy::{ExposeSecret, Secret};\nuse sqlx::PgPool;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\nuse tokio_stream::StreamExt;\nuse uuid::Uuid;\n\nuse access_control::metrics::AccessControlMetrics;\nuse app_error::AppError;\nuse appflowy_ai_client::client::AppFlowyAIClient;\nuse appflowy_collaborate::collab::cache::CollabCache;\nuse appflowy_collaborate::metrics::CollabMetrics;\nuse appflowy_collaborate::ws2::WsServer;\nuse appflowy_collaborate::CollabRealtimeMetrics;\nuse collab_stream::awareness_gossip::AwarenessGossip;\nuse collab_stream::metrics::CollabStreamMetrics;\nuse collab_stream::stream_router::StreamRouter;\nuse database::collab::CollabStore;\nuse database::file::s3_client_impl::{AwsS3BucketClientImpl, S3BucketStorage};\nuse database::user::{select_all_uid_uuid, select_uid_from_uuid};\nuse indexer::metrics::EmbeddingMetrics;\nuse indexer::scheduler::IndexerScheduler;\nuse snowflake::Snowflake;\n\nuse crate::api::metrics::{AppFlowyWebMetrics, PublishedCollabMetrics, RequestMetrics};\nuse crate::biz::chat::metrics::AIMetrics;\nuse crate::biz::pg_listener::PgListeners;\nuse crate::biz::workspace::publish::PublishedCollabStore;\nuse crate::config::config::Config;\nuse crate::mailer::AFCloudMailer;\n\npub type RedisConnectionManager = redis::aio::ConnectionManager;\n#[derive(Clone)]\npub struct AppState {\n  pub pg_pool: PgPool,\n  pub config: Arc<Config>,\n  pub user_cache: UserCache,\n  pub id_gen: Arc<RwLock<Snowflake>>,\n  pub gotrue_client: gotrue::api::Client,\n  pub redis_stream_router: Arc<StreamRouter>,\n  pub awareness_gossip: Arc<AwarenessGossip>,\n  pub redis_connection_manager: RedisConnectionManager,\n  pub collab_cache: Arc<CollabCache>,\n  pub collab_storage: Arc<dyn CollabStore>,\n  pub collab_access_control: Arc<dyn CollabAccessControl>,\n  pub workspace_access_control: Arc<dyn WorkspaceAccessControl>,\n  pub realtime_access_control: Arc<dyn RealtimeAccessControl>,\n  pub bucket_storage: Arc<S3BucketStorage>,\n  pub published_collab_store: Arc<dyn PublishedCollabStore>,\n  pub bucket_client: AwsS3BucketClientImpl,\n  pub pg_listeners: Arc<PgListeners>,\n  pub metrics: AppMetrics,\n  pub gotrue_admin: GoTrueAdmin,\n  pub mailer: AFCloudMailer,\n  pub ai_client: AppFlowyAIClient,\n  pub indexer_scheduler: Arc<IndexerScheduler>,\n  pub ws_server: Addr<WsServer>,\n}\n\nimpl AppState {\n  pub async fn load_users(_pool: &PgPool) {\n    todo!()\n  }\n\n  pub async fn next_user_id(&self) -> i64 {\n    self.id_gen.write().await.next_id()\n  }\n}\n\npub struct AuthenticateUser {\n  pub uid: i64,\n}\n\npub const EXPIRED_DURATION_DAYS: i64 = 30;\n\n#[derive(Clone)]\npub struct UserCache {\n  pool: PgPool,\n  users: Arc<DashMap<Uuid, AuthenticateUser>>,\n}\n\nimpl UserCache {\n  /// Load all users from database when initializing the cache.\n  pub async fn new(pool: PgPool) -> Self {\n    let users = {\n      let users = DashMap::new();\n      let mut stream = select_all_uid_uuid(&pool);\n      while let Some(Ok(af_user_id)) = stream.next().await {\n        users.insert(\n          af_user_id.uuid,\n          AuthenticateUser {\n            uid: af_user_id.uid,\n          },\n        );\n      }\n      users\n    };\n\n    Self {\n      pool,\n      users: Arc::new(users),\n    }\n  }\n\n  /// Get the user's uid from the cache or the database.\n  pub async fn get_user_uid(&self, uuid: &Uuid) -> Result<i64, AppError> {\n    if let Some(entry) = self.users.get(uuid) {\n      return Ok(entry.value().uid);\n    }\n\n    // If the user is not found in the cache, query the database.\n    let uid = select_uid_from_uuid(&self.pool, uuid).await?;\n    self.users.insert(*uuid, AuthenticateUser { uid });\n    Ok(uid)\n  }\n}\n\n#[derive(Clone)]\npub struct AppMetrics {\n  #[allow(dead_code)]\n  pub registry: Arc<prometheus_client::registry::Registry>,\n  pub request_metrics: Arc<RequestMetrics>,\n  pub realtime_metrics: Arc<CollabRealtimeMetrics>,\n  pub access_control_metrics: Arc<AccessControlMetrics>,\n  pub collab_metrics: Arc<CollabMetrics>,\n  pub published_collab_metrics: Arc<PublishedCollabMetrics>,\n  pub appflowy_web_metrics: Arc<AppFlowyWebMetrics>,\n  pub embedding_metrics: Arc<EmbeddingMetrics>,\n  pub collab_stream_metrics: Arc<CollabStreamMetrics>,\n  pub ai_metrics: Arc<AIMetrics>,\n}\n\nimpl Default for AppMetrics {\n  fn default() -> Self {\n    Self::new()\n  }\n}\n\nimpl AppMetrics {\n  pub fn new() -> Self {\n    let mut registry = prometheus_client::registry::Registry::default();\n    let request_metrics = Arc::new(RequestMetrics::register(&mut registry));\n    let realtime_metrics = Arc::new(CollabRealtimeMetrics::register(&mut registry));\n    let access_control_metrics = Arc::new(AccessControlMetrics::register(&mut registry));\n    let collab_metrics = Arc::new(CollabMetrics::register(&mut registry));\n    let published_collab_metrics = Arc::new(PublishedCollabMetrics::register(&mut registry));\n    let appflowy_web_metrics = Arc::new(AppFlowyWebMetrics::register(&mut registry));\n    let embedding_metrics = Arc::new(EmbeddingMetrics::register(&mut registry));\n    let collab_stream_metrics = Arc::new(CollabStreamMetrics::register(&mut registry));\n    let ai_metrics = Arc::new(AIMetrics::register(&mut registry));\n    Self {\n      registry: Arc::new(registry),\n      request_metrics,\n      realtime_metrics,\n      access_control_metrics,\n      collab_metrics,\n      published_collab_metrics,\n      appflowy_web_metrics,\n      embedding_metrics,\n      collab_stream_metrics,\n      ai_metrics,\n    }\n  }\n}\n\n#[derive(Debug, Clone)]\npub struct GoTrueAdmin {\n  pub gotrue_client: gotrue::api::Client,\n  pub jwt_secret: Secret<String>,\n  pub service_role: String,\n}\n\nimpl GoTrueAdmin {\n  pub fn new(jwt_secret: String, service_role: String, gotrue_client: gotrue::api::Client) -> Self {\n    Self {\n      jwt_secret: jwt_secret.into(),\n      gotrue_client,\n      service_role,\n    }\n  }\n\n  pub async fn token(&self) -> Result<String, AppError> {\n    let claims = GoTrueServiceRoleClaims {\n      role: self.service_role.clone(),\n    };\n    claims\n      .encode(self.jwt_secret.expose_secret().as_bytes())\n      .map_err(|err| AppError::Internal(anyhow!(err.to_string())))\n  }\n}\n"
  },
  {
    "path": "src/telemetry.rs",
    "content": "use crate::config::config::Environment;\nuse actix_web::rt::task::JoinHandle;\nuse chrono::Local;\nuse tracing::subscriber::set_global_default;\nuse tracing_subscriber::{fmt::format::Writer, EnvFilter};\n\n/// Register a subscriber as global default to process span data.\n///\n/// It should only be called once!\npub fn init_subscriber(app_env: &Environment) {\n  let builder = tracing_subscriber::fmt()\n    .with_env_filter(EnvFilter::from_default_env())\n    .with_target(true)\n    .with_thread_ids(false)\n    .with_file(true)\n    .with_line_number(true);\n\n  match app_env {\n    Environment::Local => {\n      #[cfg(feature = \"tokio-runtime-profile\")]\n      console_subscriber::ConsoleLayer::builder()\n        .retention(std::time::Duration::from_secs(60))\n        .init();\n\n      #[cfg(not(feature = \"tokio-runtime-profile\"))]\n      {\n        let subscriber = builder\n          .with_ansi(true)\n          .with_timer(CustomTime)\n          .with_target(false)\n          .with_file(false)\n          .pretty()\n          .finish();\n        set_global_default(subscriber).unwrap();\n      }\n    },\n    Environment::Production => {\n      let subscriber = builder.json().finish();\n      set_global_default(subscriber).unwrap();\n    },\n  }\n}\n\n#[allow(dead_code)]\nstruct CustomTime;\nimpl tracing_subscriber::fmt::time::FormatTime for CustomTime {\n  fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {\n    write!(w, \"{}\", Local::now().format(\"%Y-%m-%d %H:%M:%S\"))\n  }\n}\n\npub fn spawn_blocking_with_tracing<F, R>(f: F) -> JoinHandle<R>\nwhere\n  F: FnOnce() -> R + Send + 'static,\n  R: Send + 'static,\n{\n  let current_span = tracing::Span::current();\n  actix_web::rt::task::spawn_blocking(move || current_span.in_scope(f))\n}\n"
  },
  {
    "path": "tests/ai_test/asset/my_profile.txt",
    "content": "I am Lucas. I live in Singapore for 10 years and work as a software engineer. I'm a fan of AI and enjoy exploring its potential."
  },
  {
    "path": "tests/ai_test/chat_test.rs",
    "content": "use crate::ai_test::util::extract_image_url;\nuse std::time::Duration;\n\nuse appflowy_ai_client::dto::{\n  ChatQuestionQuery, OutputContent, OutputContentMetadata, OutputLayout, ResponseFormat,\n};\nuse client_api::entity::{QuestionStream, QuestionStreamValue};\nuse client_api_test::{ai_test_enabled, TestClient};\nuse futures_util::StreamExt;\nuse serde_json::json;\nuse shared_entity::dto::chat_dto::{\n  CreateAnswerMessageParams, CreateChatMessageParams, CreateChatParams, MessageCursor,\n  UpdateChatParams,\n};\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn update_chat_settings_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my first chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  // Update name and rag_ids\n  let rag1 = Uuid::new_v4();\n  let rag2 = Uuid::new_v4();\n  test_client\n    .api_client\n    .update_chat_settings(\n      &workspace_id,\n      &chat_id,\n      UpdateChatParams {\n        name: Some(\"my second chat\".to_string()),\n        metadata: None,\n        rag_ids: Some(vec![rag1.to_string(), rag2.to_string()]),\n      },\n    )\n    .await\n    .unwrap();\n\n  // Get chat settings and check if the name and rag_ids are updated\n  let settings = test_client\n    .api_client\n    .get_chat_settings(&workspace_id, &chat_id)\n    .await\n    .unwrap();\n  assert_eq!(settings.name, \"my second chat\");\n  assert_eq!(settings.rag_ids, vec![rag1.to_string(), rag2.to_string()]);\n\n  // Update chat metadata\n  test_client\n    .api_client\n    .update_chat_settings(\n      &workspace_id,\n      &chat_id,\n      UpdateChatParams {\n        name: None,\n        metadata: Some(json!({\"1\": \"A\"})),\n        rag_ids: None,\n      },\n    )\n    .await\n    .unwrap();\n  test_client\n    .api_client\n    .update_chat_settings(\n      &workspace_id,\n      &chat_id,\n      UpdateChatParams {\n        name: None,\n        metadata: Some(json!({\"2\": \"B\"})),\n        rag_ids: None,\n      },\n    )\n    .await\n    .unwrap();\n\n  // check if the metadata is updated\n  let settings = test_client\n    .api_client\n    .get_chat_settings(&workspace_id, &chat_id)\n    .await\n    .unwrap();\n  assert_eq!(settings.metadata, json!({\"1\": \"A\", \"2\": \"B\"}));\n}\n\n#[tokio::test]\nasync fn create_chat_and_create_messages_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my first chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  let mut messages = vec![];\n  for i in 0..10 {\n    let params = CreateChatMessageParams::new_system(format!(\"hello world {}\", i));\n    let question = test_client\n      .api_client\n      .create_question(&workspace_id, &chat_id, params)\n      .await\n      .unwrap();\n    messages.push(question);\n  }\n  // DESC is the default order\n  messages.reverse();\n\n  // get messages before third message. it should return first two messages even though we asked\n  // for 10 messages\n  assert_eq!(messages[7].content, \"hello world 2\");\n  let message_before_third = test_client\n    .api_client\n    .get_chat_messages(\n      &workspace_id,\n      &chat_id,\n      MessageCursor::BeforeMessageId(messages[7].message_id),\n      10,\n    )\n    .await\n    .unwrap();\n  assert!(!message_before_third.has_more);\n  assert_eq!(message_before_third.messages.len(), 2);\n  assert_eq!(message_before_third.messages[0].content, \"hello world 1\");\n  assert_eq!(message_before_third.messages[1].content, \"hello world 0\");\n\n  // get message after third message\n  assert_eq!(messages[2].content, \"hello world 7\");\n  let message_after_third = test_client\n    .api_client\n    .get_chat_messages(\n      &workspace_id,\n      &chat_id,\n      MessageCursor::AfterMessageId(messages[2].message_id),\n      2,\n    )\n    .await\n    .unwrap();\n  assert!(!message_after_third.has_more);\n  assert_eq!(message_after_third.messages.len(), 2);\n  assert_eq!(message_after_third.messages[0].content, \"hello world 9\");\n  assert_eq!(message_after_third.messages[1].content, \"hello world 8\");\n\n  let next_back = test_client\n    .api_client\n    .get_chat_messages(&workspace_id, &chat_id, MessageCursor::NextBack, 3)\n    .await\n    .unwrap();\n  assert!(next_back.has_more);\n  assert_eq!(next_back.messages.len(), 3);\n  assert_eq!(next_back.messages[0].content, \"hello world 9\");\n  assert_eq!(next_back.messages[1].content, \"hello world 8\");\n\n  let next_back = test_client\n    .api_client\n    .get_chat_messages(&workspace_id, &chat_id, MessageCursor::NextBack, 100)\n    .await\n    .unwrap();\n  assert!(!next_back.has_more);\n  assert_eq!(next_back.messages.len(), 10);\n}\n\n#[tokio::test]\nasync fn generate_chat_message_answer_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my second chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n  let params = CreateChatMessageParams::new_user(\"Hello\");\n  let question = test_client\n    .api_client\n    .create_question(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n  let answer_stream = test_client\n    .api_client\n    .stream_answer_v2(&workspace_id, &chat_id, question.message_id)\n    .await\n    .unwrap();\n  let answer = collect_answer(answer_stream, None).await;\n  assert!(!answer.is_empty());\n}\n\n// #[tokio::test]\n// async fn stop_streaming_test() {\n//   if !ai_test_enabled() {\n//     return;\n//   }\n//   let test_client = TestClient::new_user_without_ws_conn().await;\n//   let workspace_id = test_client.workspace_id().await;\n//   let chat_id = uuid::Uuid::new_v4().to_string();\n//   let params = CreateChatParams {\n//     chat_id: chat_id.clone(),\n//     name: \"Stop streaming test\".to_string(),\n//     rag_ids: vec![],\n//   };\n//\n//   test_client\n//     .api_client\n//     .create_chat(&workspace_id, params)\n//     .await\n//     .unwrap();\n//   let params = CreateChatMessageParams::new_user(\"when to use js\");\n//   let question = test_client\n//     .api_client\n//     .create_question(&workspace_id, &chat_id, params)\n//     .await\n//     .unwrap();\n//   let answer_stream = test_client\n//     .api_client\n//     .stream_answer_v2(&workspace_id, &chat_id, question.message_id)\n//     .await\n//     .unwrap();\n//   let answer = collect_answer(answer_stream, Some(1)).await;\n//   println!(\"answer:\\n{}\", answer);\n//   assert!(!answer.is_empty());\n// }\n\n#[tokio::test]\nasync fn get_format_question_message_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my ai chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  let params = CreateChatMessageParams::new_user(\n    \"what is the different between Rust and c++? Give me three points\",\n  );\n  let question = test_client\n    .api_client\n    .create_question(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n\n  let query = ChatQuestionQuery {\n    chat_id,\n    question_id: question.message_id,\n    format: ResponseFormat {\n      output_layout: OutputLayout::SimpleTable,\n      output_content: OutputContent::TEXT,\n      output_content_metadata: None,\n    },\n  };\n\n  let answer_stream = test_client\n    .api_client\n    .stream_answer_v3(&workspace_id, query, None)\n    .await\n    .unwrap();\n  let answer = collect_answer(answer_stream, None).await;\n  println!(\"answer:\\n{}\", answer);\n  assert!(!answer.is_empty());\n}\n\n#[tokio::test]\nasync fn get_text_with_image_message_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my ai chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  let params = CreateChatMessageParams::new_user(\n    \"I have a little cat. It is black with big eyes, short legs and a long tail\",\n  );\n  let question = test_client\n    .api_client\n    .create_question(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n\n  let query = ChatQuestionQuery {\n    chat_id: chat_id.clone(),\n    question_id: question.message_id,\n    format: ResponseFormat {\n      output_layout: OutputLayout::SimpleTable,\n      output_content: OutputContent::RichTextImage,\n      output_content_metadata: Some(OutputContentMetadata {\n        custom_image_prompt: None,\n        image_model: \"dall-e-3\".to_string(),\n        size: None,\n        quality: None,\n      }),\n    },\n  };\n\n  let answer_stream = test_client\n    .api_client\n    .stream_answer_v3(&workspace_id, query, None)\n    .await\n    .unwrap();\n  let answer = collect_answer(answer_stream, None).await;\n  println!(\"answer:\\n{}\", answer);\n  let image_url = extract_image_url(&answer).unwrap();\n  let (workspace_id_2, chat_id_2, file_id_2) = test_client\n    .api_client\n    .parse_blob_url_v1(&image_url)\n    .unwrap();\n  assert_eq!(workspace_id, workspace_id_2);\n  assert_eq!(chat_id, chat_id_2);\n\n  let mut retries = 6;\n  let retry_interval = Duration::from_secs(20);\n  let mut last_error = None;\n\n  // The image will be generated in the background, so we need to retry until it's available\n  while retries > 0 {\n    match test_client\n      .api_client\n      .get_blob_v1(&workspace_id_2, &chat_id_2, &file_id_2)\n      .await\n    {\n      Ok(_) => {\n        // Success, exit the loop\n        last_error = None;\n        break;\n      },\n      Err(err) => {\n        last_error = Some(err);\n        retries -= 1;\n      },\n    }\n\n    if retries > 0 {\n      tokio::time::sleep(retry_interval).await;\n    }\n  }\n\n  if let Some(err) = last_error {\n    panic!(\n      \"Failed to get blob after retries: {:?}, url:{}\",\n      err, image_url\n    );\n  }\n\n  assert!(!answer.is_empty());\n}\n\n#[tokio::test]\nasync fn get_question_message_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my ai chat\".to_string(),\n    rag_ids: vec![],\n  };\n\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  let params = CreateChatMessageParams::new_user(\"where is singapore?\");\n  let question = test_client\n    .api_client\n    .create_question(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n\n  let answer = test_client\n    .api_client\n    .get_answer(&workspace_id, &chat_id, question.message_id)\n    .await\n    .unwrap();\n\n  test_client\n    .api_client\n    .save_answer(\n      &workspace_id,\n      &chat_id,\n      CreateAnswerMessageParams {\n        content: answer.content,\n        metadata: None,\n        question_message_id: question.message_id,\n      },\n    )\n    .await\n    .unwrap();\n\n  let find_question = test_client\n    .api_client\n    .get_question_message_from_answer_id(&workspace_id, &chat_id, answer.message_id)\n    .await\n    .unwrap()\n    .unwrap();\n\n  assert_eq!(find_question.reply_message_id.unwrap(), answer.message_id);\n}\n\n#[tokio::test]\nasync fn get_model_list_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let models = test_client\n    .api_client\n    .get_model_list(&workspace_id)\n    .await\n    .unwrap()\n    .models;\n  assert!(!models.is_empty());\n  assert!(models.len() >= 2, \"models.len() = {}\", models.len());\n  println!(\"models: {:?}\", models);\n}\n\nasync fn collect_answer(\n  mut stream: QuestionStream,\n  stop_when_num_of_char: Option<usize>,\n) -> String {\n  let mut answer = String::new();\n  let mut num_of_char: usize = 0;\n  while let Some(value) = stream.next().await {\n    num_of_char += match value.unwrap() {\n      QuestionStreamValue::Answer { value } => {\n        answer.push_str(&value);\n        value.len()\n      },\n      _ => 0,\n    };\n    if let Some(stop_when_num_of_char) = stop_when_num_of_char {\n      if num_of_char >= stop_when_num_of_char {\n        break;\n      }\n    }\n  }\n  answer\n}\n"
  },
  {
    "path": "tests/ai_test/chat_with_selected_doc_test.rs",
    "content": "use crate::collab::util::{\n  alex_banker_story, alex_software_engineer_story, empty_document_editor,\n  snowboarding_in_japan_plan, TestDocumentEditor,\n};\nuse client_api_test::{ai_test_enabled, collect_answer, TestClient};\nuse collab_entity::CollabType;\nuse database_entity::dto::CreateCollabParams;\nuse futures_util::future::join_all;\nuse shared_entity::dto::chat_dto::{CreateChatMessageParams, CreateChatParams, UpdateChatParams};\nuse shared_entity::dto::workspace_dto::EmbeddedCollabQuery;\nuse std::sync::Arc;\nuse uuid::Uuid;\n\nstruct TestDoc {\n  object_id: Uuid,\n  editor: TestDocumentEditor,\n}\n\nimpl TestDoc {\n  fn new(contents: Vec<&'static str>) -> Self {\n    let object_id = Uuid::new_v4();\n    let mut editor = empty_document_editor(&object_id);\n    editor.insert_paragraphs(contents.into_iter().map(|s| s.to_string()).collect());\n\n    Self { object_id, editor }\n  }\n}\n\n#[tokio::test]\nasync fn chat_with_multiple_selected_source_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let docs = vec![\n    TestDoc::new(alex_software_engineer_story()),\n    TestDoc::new(snowboarding_in_japan_plan()),\n  ];\n\n  let test_client = Arc::new(TestClient::new_user().await);\n  let workspace_id = test_client.workspace_id().await;\n\n  // Use futures' join_all to run async tasks concurrently\n  let tasks: Vec<_> = docs\n    .iter()\n    .map(|doc| {\n      let params = CreateCollabParams {\n        workspace_id,\n        object_id: doc.object_id,\n        encoded_collab_v1: doc.editor.encode_collab().encode_to_bytes().unwrap(),\n        collab_type: CollabType::Document,\n      };\n      let cloned_test_client = Arc::clone(&test_client);\n      async move {\n        // Create collaboration and wait for embedding in parallel\n        cloned_test_client\n          .api_client\n          .create_collab(params)\n          .await\n          .unwrap();\n      }\n    })\n    .collect();\n  join_all(tasks).await;\n\n  // batch query the collab embedding info\n  let query = docs\n    .iter()\n    .map(|doc| EmbeddedCollabQuery {\n      collab_type: CollabType::Document,\n      object_id: doc.object_id,\n    })\n    .collect();\n  test_client\n    .wait_until_all_embedding(&workspace_id, query)\n    .await\n    .unwrap();\n\n  // create chat\n  let chat_id = Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my first chat\".to_string(),\n    rag_ids: vec![],\n  };\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  // use alex_software_engineer_story as chat context\n  let params = UpdateChatParams {\n    name: None,\n    metadata: None,\n    rag_ids: Some(vec![docs[0].object_id.to_string()]),\n  };\n  test_client\n    .api_client\n    .update_chat_settings(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n\n  // ask question that relate to the plan to Japan. The chat doesn't know any plan to Japan because\n  // I have added the snowboarding_in_japan_plan as a chat context.\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"When do we take off to Japan? Just tell me the date, and if you don't know, Just say you don’t know the date for the trip to Japan\",\n  )\n  .await;\n  let expected_unknown_japan_answer = r#\"I don’t know the date for your trip to Japan\"#;\n  test_client\n    .assert_similarity(\n      &workspace_id,\n      &answer,\n      expected_unknown_japan_answer,\n      0.7,\n      true,\n    )\n    .await;\n\n  // update chat context to snowboarding_in_japan_plan\n  let params = UpdateChatParams {\n    name: None,\n    metadata: None,\n    rag_ids: Some(vec![\n      docs[0].object_id.to_string(),\n      docs[1].object_id.to_string(),\n    ]),\n  };\n  test_client\n    .api_client\n    .update_chat_settings(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"when do we take off to Japan? Just tell me the date\",\n  )\n  .await;\n  let expected = r#\"\n  You take off to Japan on **January 7th**\n  \"#;\n  test_client\n    .assert_similarity(&workspace_id, &answer, expected, 0.8, false)\n    .await;\n\n  // Ask question for alex to make sure two documents are treated as chat context\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"Can you list the sports Alex enjoys? Please provide just the names, separated by commas\",\n  )\n  .await;\n  let expected = r#\"Tennis, basketball, cycling, badminton, snowboarding.\"#;\n  test_client\n    .assert_similarity(&workspace_id, &answer, expected, 0.8, true)\n    .await;\n\n  // remove the Japan plan and check the response. After remove the Japan plan, the chat should not\n  // know about the plan to Japan.\n  let params = UpdateChatParams {\n    name: None,\n    metadata: None,\n    rag_ids: Some(vec![]),\n  };\n  test_client\n    .api_client\n    .update_chat_settings(&workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"When do we take off to Japan? Just tell me the date, and if you don't know, Just say you don’t know the date for the trip to Japan\",\n  )\n  .await;\n  test_client\n    .assert_similarity(\n      &workspace_id,\n      &answer,\n      expected_unknown_japan_answer,\n      0.7,\n      true,\n    )\n    .await;\n}\n\n#[tokio::test]\nasync fn chat_with_selected_source_override_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let object_id = Uuid::new_v4();\n  let mut editor = empty_document_editor(&object_id);\n  let contents = alex_software_engineer_story();\n  editor.insert_paragraphs(contents.into_iter().map(|s| s.to_string()).collect());\n  let encode_collab = editor.encode_collab();\n\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let params = CreateCollabParams {\n    workspace_id,\n    object_id,\n    encoded_collab_v1: encode_collab.encode_to_bytes().unwrap(),\n    collab_type: CollabType::Document,\n  };\n  test_client.api_client.create_collab(params).await.unwrap();\n  test_client\n    .wait_until_get_embedding(&workspace_id, &object_id)\n    .await\n    .unwrap();\n\n  // chat with document\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: \"my first chat\".to_string(),\n    rag_ids: vec![object_id],\n  };\n\n  // create a chat\n  test_client\n    .api_client\n    .create_chat(&workspace_id, params)\n    .await\n    .unwrap();\n\n  // ask question to check the chat is using document embedding or not\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"What are some of the sports Alex enjoys, and what are his experiences with them\",\n  )\n  .await;\n  let expected = r#\"\n  Alex enjoys a variety of sports that keep him active and engaged:\n\t1.\tTennis: Learned in Singapore, he plays on weekends with friends.\n\t2.\tBasketball: Enjoys casual play, though specific details aren’t provided.\n\t3.\tCycling: Brought his bike to Singapore and looks forward to exploring parks.\n\t4.\tBadminton: Enjoys it, though details aren’t given.\n\t5.\tSnowboarding: Had an unforgettable experience on challenging slopes in Lake Tahoe.\nOverall, Alex balances his work as a software programmer with his passion for sports, finding excitement and freedom in each activity.\n  \"#;\n  test_client\n    .assert_similarity(&workspace_id, &answer, expected, 0.8, true)\n    .await;\n\n  // remove all content for given document\n  editor.clear();\n\n  // Simulate insert new content\n  let contents = alex_banker_story();\n  editor.insert_paragraphs(contents.into_iter().map(|s| s.to_string()).collect());\n  let text = editor.document.paragraphs().join(\"\");\n  let expected = alex_banker_story().join(\"\");\n  assert_eq!(text, expected);\n\n  // full sync\n  let encode_collab = editor.encode_collab();\n  test_client\n    .api_client\n    .collab_full_sync(\n      &workspace_id,\n      &object_id,\n      CollabType::Document,\n      encode_collab.doc_state.to_vec(),\n      encode_collab.state_vector.to_vec(),\n    )\n    .await\n    .unwrap();\n\n  // after full sync, chat with the same question. After update the document content, the chat\n  // should not reply with previous context.\n  let answer = ask_question(\n    &test_client,\n    &workspace_id,\n    &chat_id,\n    \"What are some of the sports Alex enjoys, and what are his experiences with them\",\n  )\n  .await;\n  let expected = r#\"\n Alex does not enjoy sports or physical activities. Instead, he prefers to relax and finds joy in\n exploring delicious food and trying new restaurants. For Alex, food is a form of relaxation and self-care,\n making it his favorite way to unwind rather than engaging in sports. While he may not have experiences with sports,\n  he certainly has many experiences in the culinary world, where he enjoys savoring flavors and discovering new dishes\n  \"#;\n  test_client\n    .assert_similarity(&workspace_id, &answer, expected, 0.8, true)\n    .await;\n}\n\nasync fn ask_question(\n  test_client: &TestClient,\n  workspace_id: &Uuid,\n  chat_id: &str,\n  question: &str,\n) -> String {\n  let params = CreateChatMessageParams::new_user(question);\n  let question = test_client\n    .api_client\n    .create_question(workspace_id, chat_id, params)\n    .await\n    .unwrap();\n  let answer_stream = test_client\n    .api_client\n    .stream_answer_v2(workspace_id, chat_id, question.message_id)\n    .await\n    .unwrap();\n  collect_answer(answer_stream).await\n}\n"
  },
  {
    "path": "tests/ai_test/completion_test.rs",
    "content": "use appflowy_ai_client::dto::{CompleteTextParams, CompletionMetadata, CompletionType};\nuse client_api_test::{ai_test_enabled, collect_completion_v2, TestClient};\n\n#[tokio::test]\nasync fn generate_chat_message_answer_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let test_client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = test_client.workspace_id().await;\n  let doc_id = uuid::Uuid::new_v4();\n\n  let params = CompleteTextParams {\n    text: \"I seen the movie last night and it was amazing\".to_string(),\n    completion_type: Some(CompletionType::SpellingAndGrammar),\n    metadata: Some(CompletionMetadata {\n      object_id: doc_id,\n      workspace_id: Some(workspace_id),\n      rag_ids: None,\n      completion_history: None,\n      custom_prompt: None,\n      prompt_id: None,\n    }),\n    format: Default::default(),\n  };\n\n  let stream = test_client\n    .api_client\n    .stream_completion_v2(&workspace_id, params, None)\n    .await\n    .unwrap();\n  let (answer, comment) = collect_completion_v2(stream).await;\n  println!(\"answer: {}\", answer);\n  println!(\"comment: {}\", comment);\n  assert!(!answer.is_empty());\n  assert!(!comment.is_empty());\n}\n"
  },
  {
    "path": "tests/ai_test/mod.rs",
    "content": "mod chat_test;\n// mod local_ai_test;\nmod summarize_row;\nmod util;\n\nmod chat_with_selected_doc_test;\nmod completion_test;\nmod summary_search_test;\n"
  },
  {
    "path": "tests/ai_test/summarize_row.rs",
    "content": "use client_api_test::{ai_test_enabled, TestClient};\nuse serde_json::json;\nuse shared_entity::dto::ai_dto::{SummarizeRowData, SummarizeRowParams};\n\n#[tokio::test]\nasync fn summarize_row_test() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  let params = SummarizeRowParams {\n    workspace_id,\n    data: SummarizeRowData::Content(\n      json!({\"name\": \"Jack\", \"age\": 25, \"city\": \"New York\"})\n        .as_object()\n        .unwrap()\n        .clone(),\n    ),\n  };\n\n  let resp = test_client.api_client.summarize_row(params).await.unwrap();\n  assert!(!resp.text.is_empty());\n}\n"
  },
  {
    "path": "tests/ai_test/summary_search_test.rs",
    "content": "use appflowy_cloud::api::search::create_ai_tool;\nuse client_api_test::{ai_test_enabled, load_env, setup_log};\nuse indexer::vector::embedder::get_open_ai_config;\nuse llm_client::chat::LLMDocument;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn chat_with_search_result_simple() {\n  if !ai_test_enabled() {\n    return;\n  }\n  let (open_ai_config, azure_config) = get_open_ai_config();\n  let ai_chat = create_ai_tool(&azure_config, &open_ai_config).unwrap();\n  let model_name = \"gpt-4o-mini\";\n  let docs = vec![\n    (\"GPT-4o-mini is typically used to suggest a streamlined version of the GPT-4 family. The idea is to create a model that maintains the core language capabilities of GPT-4 while reducing computational requirements\", Uuid::new_v4()),\n    (\"The name “Llama3.1” hints at an incremental evolution within the LLaMA (Large Language Model Meta AI) series—a family of models designed by Meta for research accessibility and performance efficiency\",Uuid::new_v4()),\n  ].into_iter().map(|(content, object_id)| LLMDocument::new(content.to_string(), object_id)).collect::<Vec<_>>();\n\n  let resp = ai_chat\n    .summarize_documents(\"gpt-4o\", model_name, docs.clone(), true)\n    .await\n    .unwrap();\n  dbg!(&resp);\n  assert_eq!(resp.summaries.len(), 1);\n  assert_eq!(resp.summaries[0].sources[0], docs[0].object_id);\n\n  let resp = ai_chat\n    .summarize_documents(\"deepseek-r1\", model_name, docs.clone(), true)\n    .await\n    .unwrap();\n  dbg!(&resp);\n  assert_eq!(resp.summaries.len(), 0);\n\n  // When only_context is false, the llm knowledge base is used to answer the question.\n  let resp = ai_chat\n    .summarize_documents(\"deepseek-r1 llm model\", model_name, docs, false)\n    .await\n    .unwrap();\n  dbg!(&resp);\n  assert_eq!(resp.summaries.len(), 1);\n  assert!(resp.summaries[0].sources.is_empty());\n}\n\n#[tokio::test]\nasync fn summary_search_result() {\n  if !ai_test_enabled() {\n    return;\n  }\n  load_env();\n  setup_log();\n  let (open_ai_config, azure_config) = get_open_ai_config();\n  let ai_chat = create_ai_tool(&azure_config, &open_ai_config).unwrap();\n  let model_name = \"gpt-4o-mini\";\n  let docs = vec![\n    (\"Rust is a multiplayer survival game developed by Facepunch Studios, first released in early access in December 2013 and fully launched in February 2018. It has since become one of the most popular games in the survival genre, known for its harsh environment, intricate crafting system, and player-driven dynamics. The game is available on Windows, macOS, and PlayStation, with a community-driven approach to updates and content additions.\", uuid::Uuid::new_v4()),\n    (\"Rust is a modern, system-level programming language designed with a focus on performance, safety, and concurrency. It was created by Mozilla and first released in 2010, with its 1.0 version launched in 2015. Rust is known for providing the control and performance of languages like C and C++, but with built-in safety features that prevent common programming errors, such as memory leaks, data races, and buffer overflows.\", uuid::Uuid::new_v4()),\n    (\"Rust as a Natural Process (Oxidation) refers to the chemical reaction that occurs when metals, primarily iron, come into contact with oxygen and moisture (water) over time, leading to the formation of iron oxide, commonly known as rust. This process is a form of oxidation, where a substance reacts with oxygen in the air or water, resulting in the degradation of the metal.\", uuid::Uuid::new_v4()),\n  ].into_iter().map(|(content, object_id)| LLMDocument::new(content.to_string(), object_id)).collect::<Vec<_>>();\n\n  let resp = ai_chat\n    .summarize_documents(\"What is Rust\", model_name, docs.clone(), true)\n    .await\n    .unwrap();\n  dbg!(&resp);\n  assert_eq!(resp.summaries.len(), 1);\n  assert!(resp.summaries[0].sources.len() > 1);\n\n  let resp = ai_chat\n    .summarize_documents(\"multiplayer game\", model_name, docs.clone(), true)\n    .await\n    .unwrap();\n  dbg!(&resp);\n  assert_eq!(resp.summaries.len(), 1);\n  assert_eq!(resp.summaries[0].sources.len(), 1);\n  assert_eq!(resp.summaries[0].sources[0], docs[0].object_id);\n}\n"
  },
  {
    "path": "tests/ai_test/util.rs",
    "content": "use fancy_regex::Regex;\nuse std::fs::File;\nuse std::io::Read;\n\n#[allow(dead_code)]\npub(crate) fn read_text_from_asset(file_name: &str) -> String {\n  let mut file = File::open(format!(\"./tests/ai_test/asset/{}\", file_name)).unwrap();\n  let mut buffer = Vec::new();\n  file.read_to_end(&mut buffer).unwrap();\n  String::from_utf8(buffer).unwrap()\n}\n\npub fn extract_image_url(text: &str) -> Option<String> {\n  // Define a regex pattern to match the image URL inside ![]()\n  let re = Regex::new(r\"!\\[\\]\\((https?://[^\\s]+)\\)\").unwrap();\n\n  // Search for the first match in the text\n  if let Ok(Some(captures)) = re.captures(text) {\n    // Extract the matched group (the URL)\n    captures.get(1).map(|m| m.as_str().to_string())\n  } else {\n    None\n  }\n}\n"
  },
  {
    "path": "tests/collab/awareness_test.rs",
    "content": "use std::time::Duration;\n\nuse client_api_test::TestClient;\nuse collab_entity::CollabType;\nuse database_entity::dto::AFRole;\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn viewing_document_editing_users_test() {\n  let collab_type = CollabType::Unknown;\n  let mut owner = TestClient::new_user().await;\n  let mut guest = TestClient::new_user().await;\n\n  let workspace_id = owner.workspace_id().await;\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Member)\n    .await\n    .unwrap();\n\n  let object_id = owner\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  let owner_uid = owner.uid().await;\n  let clients = owner.get_connect_users(&object_id).await;\n  assert_eq!(clients.len(), 1, \"guest shouldn't be connected yet\");\n  assert_eq!(clients[0], owner_uid);\n\n  guest\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  guest.wait_object_sync_complete(&object_id).await.unwrap();\n  sleep(Duration::from_secs(1)).await;\n\n  // after guest open the collab, it will emit an awareness that contains the user id of guest.\n  // This awareness will be sent to the server. Server will broadcast the awareness to all the clients\n  // that are connected to the collab.\n  let guest_uid = guest.uid().await;\n  let mut clients: Vec<i64> = owner.get_connect_users(&object_id).await;\n  clients.sort();\n\n  let mut expected_clients = [owner_uid, guest_uid];\n  expected_clients.sort();\n\n  assert_eq!(clients.len(), 2, \"expected owner and member connected\");\n  assert_eq!(clients, expected_clients);\n  // simulate the guest close the collab\n  guest.clean_awareness_state(&object_id).await;\n  // sleep 5 second to make sure the awareness observe callback is called\n  sleep(Duration::from_secs(5)).await;\n  guest.wait_object_sync_complete(&object_id).await.unwrap();\n  let clients = owner.get_connect_users(&object_id).await;\n  assert_eq!(clients.len(), 1, \"expected only owner connected\");\n  assert_eq!(clients[0], owner_uid);\n\n  // simulate the guest open the collab again\n  guest.emit_awareness_state(&object_id).await;\n  // sleep 2 second to make sure the awareness observe callback is called\n  sleep(Duration::from_secs(2)).await;\n  guest.wait_object_sync_complete(&object_id).await.unwrap();\n\n  assert_num_connected_client_within_secs(&owner, &object_id, 2, 30).await;\n}\n\nasync fn assert_num_connected_client_within_secs(\n  client: &TestClient,\n  object_id: &Uuid,\n  expected: usize,\n  secs: u64,\n) {\n  let mut retry_count = 0;\n  loop {\n    tokio::select! {\n       _ = tokio::time::sleep(Duration::from_secs(secs)) => {\n         panic!(\"timeout\");\n       },\n       clients = client.get_connect_users(object_id) => {\n        retry_count += 1;\n        if retry_count > 30 {\n          assert_eq!(clients.len(), expected);\n          break;\n        }\n        if clients.len() == expected {\n            break;\n        }\n        tokio::time::sleep(Duration::from_millis(1000)).await;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "tests/collab/collab_curd_test.rs",
    "content": "use app_error::ErrorCode;\nuse assert_json_diff::assert_json_include;\nuse collab::core::collab::default_client_id;\nuse collab::entity::EncodedCollab;\nuse collab_document::document_data::default_document_collab_data;\nuse collab_entity::CollabType;\nuse database_entity::dto::{\n  CollabParams, CreateCollabData, CreateCollabParams, QueryCollab, QueryCollabParams,\n  QueryCollabResult,\n};\n\nuse reqwest::Method;\nuse serde::Serialize;\nuse serde_json::json;\n\nuse crate::collab::util::{empty_document_editor, generate_random_string, test_encode_collab_v1};\nuse client_api::process_response_data;\nuse client_api_test::TestClient;\nuse uuid::Uuid;\n\nconst WORKSPACE_ID: Uuid = Uuid::from_u128(70700);\n\n#[tokio::test]\nasync fn get_collab_response_compatible_test() {\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  // after 0.3.22, we use [CollabResponse] instead of EncodedCollab as the response\n  let collab_resp = test_client\n    .get_collab(workspace_id, workspace_id, CollabType::Folder)\n    .await\n    .unwrap();\n  assert_eq!(collab_resp.object_id, workspace_id);\n\n  let json = serde_json::to_value(collab_resp.clone()).unwrap();\n  let encode_collab: EncodedCollab = serde_json::from_value(json).unwrap();\n  assert_eq!(collab_resp.encode_collab, encode_collab);\n}\n\n#[tokio::test]\nasync fn batch_insert_collab_with_empty_payload_test() {\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  let error = test_client\n    .create_collab_list(&workspace_id, vec![])\n    .await\n    .unwrap_err();\n\n  assert_eq!(error.code, ErrorCode::InvalidRequest);\n}\n\n#[tokio::test]\nasync fn create_collab_params_compatibility_serde_test() {\n  // This test is to make sure that the CreateCollabParams is compatible with the old InsertCollabParams\n  let object_id = uuid::Uuid::new_v4();\n  let encoded_collab_v1 = default_document_collab_data(&object_id.to_string(), default_client_id())\n    .unwrap()\n    .encode_to_bytes()\n    .unwrap();\n\n  let old_version_value = json!(InsertCollabParams {\n    object_id,\n    encoded_collab_v1: encoded_collab_v1.clone(),\n    workspace_id: WORKSPACE_ID,\n    collab_type: CollabType::Document,\n  });\n\n  let new_version_create_params =\n    serde_json::from_value::<CreateCollabParams>(old_version_value.clone()).unwrap();\n\n  let new_version_value = serde_json::to_value(new_version_create_params.clone()).unwrap();\n  assert_json_include!(actual: new_version_value.clone(), expected: old_version_value.clone());\n\n  assert_eq!(new_version_create_params.object_id, object_id);\n  assert_eq!(\n    new_version_create_params.encoded_collab_v1,\n    encoded_collab_v1\n  );\n  assert_eq!(new_version_create_params.workspace_id, WORKSPACE_ID);\n  assert_eq!(new_version_create_params.collab_type, CollabType::Document);\n}\n\n#[derive(Serialize)]\nstruct InsertCollabParams {\n  pub object_id: Uuid,\n  pub encoded_collab_v1: Vec<u8>,\n  pub workspace_id: Uuid,\n  pub collab_type: CollabType,\n}\n\n#[tokio::test]\nasync fn create_collab_compatibility_with_json_params_test() {\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n  let api_client = &test_client.api_client;\n  let url = format!(\n    \"{}/api/workspace/{}/collab/{}\",\n    api_client.base_url, workspace_id, &object_id\n  );\n\n  let encoded_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\");\n  let params = OldCreateCollabParams {\n    inner: CreateCollabData {\n      object_id,\n      encoded_collab_v1: encoded_collab.encode_to_bytes().unwrap().into(),\n      collab_type: CollabType::Unknown,\n    },\n    workspace_id,\n  };\n\n  test_client\n    .api_client\n    .http_client_with_auth(Method::POST, &url)\n    .await\n    .unwrap()\n    .json(&params)\n    .send()\n    .await\n    .unwrap();\n\n  let resp = test_client\n    .api_client\n    .http_client_with_auth(Method::GET, &url)\n    .await\n    .unwrap()\n    .json(&QueryCollabParams {\n      workspace_id,\n      inner: QueryCollab {\n        object_id,\n        collab_type: CollabType::Unknown,\n      },\n    })\n    .send()\n    .await\n    .unwrap();\n\n  let encoded_collab_from_server = process_response_data::<EncodedCollab>(resp).await.unwrap();\n  assert_eq!(encoded_collab, encoded_collab_from_server);\n}\n\n#[tokio::test]\nasync fn batch_insert_document_collab_test() {\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n\n  let num_collabs = 100;\n  let mut list = vec![];\n  for _ in 0..num_collabs {\n    let object_id = Uuid::new_v4();\n    let mut editor = empty_document_editor(&object_id);\n    let paragraphs = vec![\n      generate_random_string(1),\n      generate_random_string(2),\n      generate_random_string(5),\n    ];\n    editor.insert_paragraphs(paragraphs);\n    list.push((object_id, editor.encode_collab()));\n  }\n\n  let params_list = list\n    .iter()\n    .map(|(object_id, encoded_collab_v1)| CollabParams {\n      object_id: *object_id,\n      encoded_collab_v1: encoded_collab_v1.encode_to_bytes().unwrap().into(),\n      collab_type: CollabType::Document,\n      updated_at: None,\n    })\n    .collect::<Vec<_>>();\n\n  test_client\n    .create_collab_list(&workspace_id, params_list.clone())\n    .await\n    .unwrap();\n\n  let params = params_list\n    .iter()\n    .map(|params| QueryCollab {\n      object_id: params.object_id,\n      collab_type: params.collab_type,\n    })\n    .collect::<Vec<_>>();\n\n  let result = test_client\n    .batch_get_collab(&workspace_id, params)\n    .await\n    .unwrap();\n\n  for params in params_list {\n    let encoded_collab = result.0.get(&params.object_id).unwrap();\n    match encoded_collab {\n      QueryCollabResult::Success { encode_collab_v1 } => {\n        let actual = EncodedCollab::decode_from_bytes(encode_collab_v1.as_ref()).unwrap();\n        let expected = EncodedCollab::decode_from_bytes(params.encoded_collab_v1.as_ref()).unwrap();\n        assert_eq!(actual.doc_state, expected.doc_state);\n      },\n      QueryCollabResult::Failed { error } => {\n        panic!(\"Failed to get collab: {:?}\", error);\n      },\n    }\n  }\n\n  assert_eq!(result.0.values().len(), num_collabs);\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct OldCreateCollabParams {\n  #[serde(flatten)]\n  inner: CreateCollabData,\n  pub workspace_id: Uuid,\n}\n"
  },
  {
    "path": "tests/collab/collab_embedding_test.rs",
    "content": "use crate::collab::util::empty_document_editor;\nuse client_api_test::TestClient;\nuse collab_entity::CollabType;\nuse database_entity::dto::CreateCollabParams;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn query_collab_embedding_after_create_test() {\n  let object_id = Uuid::new_v4();\n  let mut editor = empty_document_editor(&object_id);\n  let contents = vec![\n    \"AppFlowy is an open-source project.\",\n    \"It is an alternative to tools like Notion.\",\n    \"AppFlowy provides full control of your data.\",\n    \"The project is built using Flutter for the frontend.\",\n    \"Rust powers AppFlowy's backend for safety and performance.\",\n    \"AppFlowy supports both personal and collaborative workflows.\",\n    \"It is customizable and self-hostable.\",\n    \"Users can create documents, databases, and workflows with AppFlowy.\",\n    \"The community contributes actively to AppFlowy's development.\",\n    \"AppFlowy aims to be fast, reliable, and feature-rich.\",\n  ];\n  editor.insert_paragraphs(contents.into_iter().map(|s| s.to_string()).collect());\n\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let params = CreateCollabParams {\n    workspace_id,\n    object_id,\n    encoded_collab_v1: editor.encode_collab().encode_to_bytes().unwrap(),\n    collab_type: CollabType::Document,\n  };\n  test_client.api_client.create_collab(params).await.unwrap();\n  test_client\n    .wait_until_get_embedding(&workspace_id, &object_id)\n    .await\n    .unwrap();\n}\n\n#[tokio::test]\nasync fn document_full_sync_then_search_test() {\n  let object_id = Uuid::new_v4();\n  let mut local_document = empty_document_editor(&object_id);\n  let test_client = TestClient::new_user().await;\n  let uid = test_client.uid().await;\n  let workspace_id = test_client.workspace_id().await;\n  let doc_state = local_document.encode_collab().encode_to_bytes().unwrap();\n  let params = CreateCollabParams {\n    workspace_id,\n    object_id,\n    encoded_collab_v1: doc_state,\n    collab_type: CollabType::Document,\n  };\n  test_client.api_client.create_collab(params).await.unwrap();\n  test_client\n    .insert_view_to_general_space(\n      &workspace_id,\n      &object_id.to_string(),\n      \"AppFlowy\",\n      collab_folder::ViewLayout::Document,\n      uid,\n    )\n    .await;\n\n  let contents = vec![\n    \"AppFlowy is an open-source project.\",\n    \"It is an alternative to tools like Notion.\",\n    \"AppFlowy provides full control of your data.\",\n    \"The project is built using Flutter for the frontend.\",\n    \"Rust powers AppFlowy's backend for safety and performance.\",\n    \"AppFlowy supports both personal and collaborative workflows.\",\n    \"It is customizable and self-hostable.\",\n    \"Users can create documents, databases, and workflows with AppFlowy.\",\n    \"The community contributes actively to AppFlowy's development.\",\n    \"AppFlowy aims to be fast, reliable, and feature-rich.\",\n  ];\n  local_document.insert_paragraphs(contents.into_iter().map(|s| s.to_string()).collect());\n  let encode_collab = local_document.encode_collab();\n\n  // After full sync, two document should be the same\n  test_client\n    .api_client\n    .collab_full_sync(\n      &workspace_id,\n      &object_id,\n      CollabType::Document,\n      encode_collab.doc_state.to_vec(),\n      encode_collab.state_vector.to_vec(),\n    )\n    .await\n    .unwrap();\n\n  let remote_document = test_client\n    .create_document_collab(workspace_id, object_id)\n    .await;\n  let remote_plain_text = remote_document.paragraphs().join(\"\");\n  let local_plain_text = local_document.document.paragraphs().join(\"\");\n  assert_eq!(local_plain_text, remote_plain_text);\n\n  let items = test_client\n    .wait_unit_get_search_result(&workspace_id, \"workflows\", 1, 200, Some(0.3))\n    .await\n    .unwrap();\n  assert_eq!(items.len(), 1);\n  assert_eq!(items[0].preview, Some(\"AppFlowy is an open-source project.It is an alternative to tools like Notion.AppFlowy provides full control of your data.The project is built using Flutter for the frontend.Rust powers AppFlowy's back\".to_string()));\n}\n"
  },
  {
    "path": "tests/collab/database_crud.rs",
    "content": "use std::collections::HashMap;\n\nuse client_api_test::{generate_unique_registered_user_client, workspace_id_from_client};\nuse collab_database::entity::FieldType;\nuse serde_json::json;\nuse shared_entity::dto::workspace_dto::AFInsertDatabaseField;\n\n#[tokio::test]\nasync fn database_row_upsert_with_doc() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let databases = c.list_databases(&workspace_id).await.unwrap();\n  assert_eq!(databases.len(), 1);\n\n  let todo_db = &databases[0];\n\n  // Upsert row\n  let row_id = c\n    .upsert_database_item(\n      &workspace_id,\n      &todo_db.id,\n      \"my_pre_hash_123\".to_string(),\n      HashMap::from([]),\n      Some(\"This is a document of a database row\".to_string()),\n    )\n    .await\n    .unwrap();\n\n  {\n    // Get row and check data\n    let row_detail = &c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&row_id], true)\n      .await\n      .unwrap()[0];\n    assert!(row_detail.has_doc);\n    assert_eq!(\n      row_detail.doc,\n      Some(String::from(\"This is a document of a database row\"))\n    );\n    let row_uuid = uuid::Uuid::parse_str(&row_id).unwrap();\n    let row_collab_doc_exists = &c\n      .check_if_row_document_collab_exists(&workspace_id, &row_uuid)\n      .await\n      .unwrap();\n    assert!(row_collab_doc_exists)\n  }\n  // Upsert row with another doc\n  let _ = c\n    .upsert_database_item(\n      &workspace_id,\n      &todo_db.id,\n      \"my_pre_hash_123\".to_string(),\n      HashMap::from([]),\n      Some(\"This is a another document\".to_string()),\n    )\n    .await\n    .unwrap();\n  {\n    // Get row and check that doc has been modified\n    let row_detail = &c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&row_id], true)\n      .await\n      .unwrap()[0];\n    assert_eq!(\n      row_detail.doc,\n      Some(String::from(\"This is a another document\"))\n    );\n  }\n}\n\n#[tokio::test]\nasync fn database_row_upsert() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let databases = c.list_databases(&workspace_id).await.unwrap();\n  assert_eq!(databases.len(), 1);\n\n  let todo_db = &databases[0];\n\n  // predefined string to be used to identify the row\n  let pre_hash = String::from(\"my_id_123\");\n\n  // Upsert row\n  let row_id = c\n    .upsert_database_item(\n      &workspace_id,\n      &todo_db.id,\n      pre_hash.clone(),\n      HashMap::from([\n        (String::from(\"Description\"), json!(\"description_1\")),\n        (String::from(\"Status\"), json!(\"To Do\")),\n        (String::from(\"Multiselect\"), json!([\"social\", \"news\"])),\n      ]),\n      Some(\"\".to_string()),\n    )\n    .await\n    .unwrap();\n\n  {\n    // Get row and check data\n    let row_detail = &c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&row_id], false)\n      .await\n      .unwrap()[0];\n    assert_eq!(row_detail.cells[\"Description\"], \"description_1\");\n    assert_eq!(row_detail.cells[\"Status\"], \"To Do\");\n    assert_eq!(row_detail.cells[\"Multiselect\"][0], \"social\");\n    assert_eq!(row_detail.cells[\"Multiselect\"][1], \"news\");\n    assert!(!row_detail.has_doc);\n  }\n\n  {\n    // Upsert row again with different data, using same pre_hash\n    // row_id return should be the same as previous\n    let row_id_2 = c\n      .upsert_database_item(\n        &workspace_id,\n        &todo_db.id,\n        pre_hash,\n        HashMap::from([\n          (String::from(\"Description\"), json!(\"description_2\")),\n          (String::from(\"Status\"), json!(\"Doing\")),\n          (String::from(\"Multiselect\"), json!([\"fast\", \"self-host\"])),\n        ]),\n        Some(\"This is a document of a database row\".to_string()),\n      )\n      .await\n      .unwrap();\n    assert_eq!(row_id, row_id_2);\n  }\n  {\n    // Get row and check data, it should be modified\n    let row_detail = &c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&row_id], true)\n      .await\n      .unwrap()[0];\n    assert_eq!(row_detail.cells[\"Description\"], \"description_2\");\n    assert_eq!(row_detail.cells[\"Status\"], \"Doing\");\n    assert_eq!(row_detail.cells[\"Multiselect\"][0], \"fast\");\n    assert_eq!(row_detail.cells[\"Multiselect\"][1], \"self-host\");\n    assert!(row_detail.has_doc);\n    assert_eq!(\n      row_detail.doc,\n      Some(\"This is a document of a database row\".to_string())\n    );\n  }\n}\n\n#[tokio::test]\nasync fn database_fields_crud() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let databases = c.list_databases(&workspace_id).await.unwrap();\n  assert_eq!(databases.len(), 1);\n  let todo_db = &databases[0];\n\n  let my_num_field_id = {\n    c.add_database_field(\n      &workspace_id,\n      &todo_db.id,\n      &AFInsertDatabaseField {\n        name: \"MyNumberColumn\".to_string(),\n        field_type: FieldType::Number.into(),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap()\n  };\n  let my_datetime_field_id = {\n    c.add_database_field(\n      &workspace_id,\n      &todo_db.id,\n      &AFInsertDatabaseField {\n        name: \"MyDateTimeColumn\".to_string(),\n        field_type: FieldType::DateTime.into(),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap()\n  };\n  let my_url_field_id = {\n    c.add_database_field(\n      &workspace_id,\n      &todo_db.id,\n      &AFInsertDatabaseField {\n        name: \"MyUrlField\".to_string(),\n        field_type: FieldType::URL.into(),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap()\n  };\n  let my_checkbox_field_id = {\n    c.add_database_field(\n      &workspace_id,\n      &todo_db.id,\n      &AFInsertDatabaseField {\n        name: \"MyCheckboxColumn\".to_string(),\n        field_type: FieldType::Checkbox.into(),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap()\n  };\n  {\n    let my_description = \"my task 123\";\n    let my_status = \"To Do\";\n    let new_row_id = c\n      .add_database_item(\n        &workspace_id,\n        &todo_db.id,\n        HashMap::from([\n          (String::from(\"Description\"), json!(my_description)),\n          (String::from(\"Status\"), json!(my_status)),\n          (String::from(\"Multiselect\"), json!([\"social\", \"news\"])),\n          (my_num_field_id, json!(123)),\n          (my_datetime_field_id, json!(1733210221)),\n          (my_url_field_id, json!(\"https://appflowy.io\")),\n          (my_checkbox_field_id, json!(true)),\n        ]),\n        None,\n      )\n      .await\n      .unwrap();\n\n    let row_details = c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&new_row_id], false)\n      .await\n      .unwrap();\n    assert_eq!(row_details.len(), 1);\n    let new_row_detail = &row_details[0];\n    println!(\n      \"Available keys in cells: {:?}\",\n      new_row_detail.cells.keys().collect::<Vec<_>>()\n    );\n    println!(\"Number of cells: {}\", new_row_detail.cells.len());\n    for (key, value) in &new_row_detail.cells {\n      println!(\"Cell '{}': {:?}\", key, value);\n    }\n\n    assert_eq!(new_row_detail.cells[\"Description\"], my_description);\n    assert_eq!(new_row_detail.cells[\"Status\"], my_status);\n    assert_eq!(new_row_detail.cells[\"Multiselect\"][0], \"social\");\n    assert_eq!(new_row_detail.cells[\"Multiselect\"][1], \"news\");\n    assert_eq!(new_row_detail.cells[\"MyNumberColumn\"], \"123\");\n    assert_eq!(\n      new_row_detail.cells[\"MyDateTimeColumn\"],\n      json!({\n        \"end\": serde_json::Value::Null,\n        \"pretty_end_date\": serde_json::Value::Null,\n        \"pretty_end_datetime\": serde_json::Value::Null,\n        \"pretty_end_time\": serde_json::Value::Null,\n        \"pretty_start_date\": \"2024-12-03\",\n        \"pretty_start_datetime\": \"2024-12-03 07:17:01 UTC\",\n        \"pretty_start_time\": \"07:17:01\",\n        \"start\": \"2024-12-03T07:17:01+00:00\",\n        \"timezone\": \"UTC\",\n      }),\n    );\n    assert_eq!(new_row_detail.cells[\"MyUrlField\"], \"https://appflowy.io\");\n    assert_eq!(new_row_detail.cells[\"MyCheckboxColumn\"], true);\n  }\n}\n\n#[tokio::test]\nasync fn database_fields_unsupported_field_type() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let databases = c.list_databases(&workspace_id).await.unwrap();\n  assert_eq!(databases.len(), 1);\n  let todo_db = &databases[0];\n\n  let my_rel_field_id = {\n    c.add_database_field(\n      &workspace_id,\n      &todo_db.id,\n      &AFInsertDatabaseField {\n        name: \"MyRelationCol\".to_string(),\n        field_type: FieldType::Relation.into(),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap()\n  };\n  {\n    let my_description = \"my task 123\";\n    let my_status = \"To Do\";\n    let new_row_id = c\n      .add_database_item(\n        &workspace_id,\n        &todo_db.id,\n        HashMap::from([\n          (String::from(\"Description\"), json!(my_description)),\n          (String::from(\"Status\"), json!(my_status)),\n          (String::from(\"Multiselect\"), json!([\"social\", \"news\"])),\n          (my_rel_field_id, json!(\"relation_data\")),\n        ]),\n        None,\n      )\n      .await\n      .unwrap();\n\n    let row_details = c\n      .list_database_row_details(&workspace_id, &todo_db.id, &[&new_row_id], false)\n      .await\n      .unwrap();\n    assert_eq!(row_details.len(), 1);\n    let new_row_detail = &row_details[0];\n    assert!(!new_row_detail.cells.contains_key(\"MyRelationCol\"));\n  }\n}\n\n#[tokio::test]\nasync fn database_insert_row_with_doc() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let databases = c.list_databases(&workspace_id).await.unwrap();\n  assert_eq!(databases.len(), 1);\n  let todo_db = &databases[0];\n\n  let row_doc = \"This is a document of a database row\";\n  let new_row_id = c\n    .add_database_item(\n      &workspace_id,\n      &todo_db.id,\n      HashMap::from([]),\n      row_doc.to_string().into(),\n    )\n    .await\n    .unwrap();\n\n  let row_details = c\n    .list_database_row_details(&workspace_id, &todo_db.id, &[&new_row_id], true)\n    .await\n    .unwrap();\n  let row_detail = &row_details[0];\n  assert!(row_detail.has_doc);\n  assert_eq!(\n    row_detail.doc,\n    Some(\"This is a document of a database row\".to_string())\n  );\n}\n"
  },
  {
    "path": "tests/collab/missing_update_test.rs",
    "content": "use std::time::Duration;\n\nuse client_api::entity::AFRole;\nuse client_api_test::{assert_client_collab_include_value, TestClient};\nuse collab_entity::CollabType;\nuse serde_json::{json, Value};\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn client_apply_update_find_missing_update_test() {\n  let (_client_1, mut client_2, object_id, mut expected_json) = make_clients().await;\n  // \"title\" => \"hello world\" is not delivered to client_2 and is considered a missing update\n  client_2.enable_receive_message();\n  {\n    let mut lock = client_2\n      .collabs\n      .get_mut(&object_id)\n      .unwrap()\n      .collab\n      .write()\n      .await;\n    lock.insert(\"content\", \"hello world\");\n  }\n\n  expected_json[\"content\"] = Value::String(\"hello world\".to_string());\n\n  // the collab ping will trigger a init sync with reason InitSyncReason::MissUpdates after a period of time\n  assert_client_collab_include_value(&mut client_2, &object_id, expected_json)\n    .await\n    .unwrap();\n}\n\n#[tokio::test]\nasync fn client_ping_find_missing_update_test() {\n  let (_client_1, mut client_2, object_id, expected_json) = make_clients().await;\n  // \"title\" => \"hello world\" is not delivered to client_2 and is considered a missing update\n  client_2.enable_receive_message();\n\n  // the collab ping will trigger a init sync with reason InitSyncReason::MissUpdates after a period of time\n  assert_client_collab_include_value(&mut client_2, &object_id, expected_json)\n    .await\n    .unwrap();\n}\n\n/// Create two clients and the first client makes an edit to the collaborative document.\n/// The second client did do init sync but disable receive message, so it will miss the first edit.\nasync fn make_clients() -> (TestClient, TestClient, Uuid, Value) {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n  // Create a collaborative document with client_1 and invite client_2 to collaborate.\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  // after client 2 finish init sync and then disable receive message\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  client_2.disable_receive_message();\n\n  // Client_1 makes the first edit by inserting \"task 1\".\n  {\n    let mut lock = client_1\n      .collabs\n      .get_mut(&object_id)\n      .unwrap()\n      .collab\n      .write()\n      .await;\n    lock.insert(\"title\", \"hello world\");\n  }\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  sleep(Duration::from_secs(2)).await;\n  let expected_json = json!({\n    \"title\": \"hello world\"\n  });\n  (client_1, client_2, object_id, expected_json)\n}\n"
  },
  {
    "path": "tests/collab/mod.rs",
    "content": "mod awareness_test;\nmod collab_curd_test;\nmod collab_embedding_test;\nmod database_crud;\nmod multi_devices_edit;\nmod permission_test;\nmod single_device_edit;\nmod storage_test;\nmod stress_test;\npub mod util;\nmod web_edit;\n\n#[cfg(not(feature = \"sync-v2\"))]\nmod missing_update_test;\n"
  },
  {
    "path": "tests/collab/multi_devices_edit.rs",
    "content": "use client_api::entity::AFRole;\nuse client_api_test::*;\nuse collab_entity::CollabType;\nuse database_entity::dto::QueryCollabParams;\nuse serde_json::json;\nuse sqlx::types::uuid;\nuse tracing::trace;\n\n#[tokio::test]\nasync fn sync_collab_content_after_reconnect_test() {\n  let object_id = uuid::Uuid::new_v4();\n  let collab_type = CollabType::Unknown;\n\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  test_client\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  // Disconnect the client and edit the collab. The updates will not be sent to the server.\n  test_client.disconnect().await;\n  for i in 0..=5 {\n    test_client\n      .insert_into(&object_id, &i.to_string(), i.to_string())\n      .await;\n  }\n\n  // it will return RecordNotFound error when trying to get the collab from the server\n  let err = test_client\n    .api_client\n    .get_collab(QueryCollabParams::new(object_id, collab_type, workspace_id))\n    .await\n    .unwrap_err();\n  assert!(err.is_record_not_found());\n\n  // After reconnect the collab should be synced to the server.\n  test_client.reconnect().await;\n  // Wait for the messages to be sent\n  test_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    json!( {\n      \"0\": \"0\",\n      \"1\": \"1\",\n      \"2\": \"2\",\n      \"3\": \"3\",\n      \"4\": \"4\",\n      \"5\": \"5\",\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn same_client_connect_then_edit_multiple_time_test() {\n  let collab_type = CollabType::Unknown;\n  let registered_user = generate_unique_registered_user().await;\n  let mut client_1 = TestClient::user_with_new_device(registered_user.clone()).await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // client 1 edit the collab\n  client_1.insert_into(&object_id, \"1\", \"a\").await;\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  client_1.disconnect().await;\n\n  client_1.insert_into(&object_id, \"2\", \"b\").await;\n  client_1.reconnect().await;\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  for _ in 0..5 {\n    client_1.reconnect().await;\n    client_1\n      .wait_object_sync_complete(&object_id)\n      .await\n      .unwrap();\n    client_1.disconnect().await;\n  }\n\n  //FIXME: for some reason the disconnect/reconnect is not working properly on old client\n  // potentially due to the way, how it's not handling queued messages when disconnect/reconnect\n  // happens\n  client_1.reconnect().await;\n\n  let expected_json = json!({\n    \"1\": \"a\",\n    \"2\": \"b\"\n  });\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    30,\n    expected_json.clone(),\n  )\n  .await\n  .unwrap();\n\n  assert_client_collab_value(&mut client_1, &object_id, expected_json)\n    .await\n    .unwrap();\n}\n\n#[tokio::test]\nasync fn same_client_with_diff_devices_edit_same_collab_test() {\n  let collab_type = CollabType::Unknown;\n  let registered_user = generate_unique_registered_user().await;\n  let mut client_1 = TestClient::user_with_new_device(registered_user.clone()).await;\n  let mut client_2 = TestClient::user_with_new_device(registered_user.clone()).await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // client 1 edit the collab\n  client_1.insert_into(&object_id, \"name\", \"workspace1\").await;\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    30,\n    json!({\n      \"name\": \"workspace1\"\n    }),\n  )\n  .await\n  .unwrap();\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  trace!(\"client 2 disconnect: {:?}\", client_2.device_id);\n  client_2.disconnect().await;\n  client_2.insert_into(&object_id, \"name\", \"workspace2\").await;\n  client_2.reconnect().await;\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  let expected_json = json!({\n    \"name\": \"workspace2\"\n  });\n\n  assert_client_collab_within_secs(&mut client_2, &object_id, \"name\", expected_json.clone(), 60)\n    .await;\n\n  assert_client_collab_within_secs(&mut client_1, &object_id, \"name\", expected_json.clone(), 60)\n    .await;\n}\n\n#[tokio::test]\nasync fn same_client_with_diff_devices_edit_diff_collab_test() {\n  let registered_user = generate_unique_registered_user().await;\n  let collab_type = CollabType::Unknown;\n  let mut device_1 = TestClient::user_with_new_device(registered_user.clone()).await;\n  let mut device_2 = TestClient::user_with_new_device(registered_user.clone()).await;\n\n  let workspace_id = device_1.workspace_id().await;\n\n  // different devices create different collabs. the collab will be synced between devices\n  let object_id_1 = device_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n  let object_id_2 = device_2\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // client 1 edit the collab with object_id_1\n  device_1.insert_into(&object_id_1, \"name\", \"object 1\").await;\n  device_1\n    .wait_object_sync_complete(&object_id_1)\n    .await\n    .unwrap();\n\n  // client 2 edit the collab with object_id_2\n  device_2.insert_into(&object_id_2, \"name\", \"object 2\").await;\n  device_2\n    .wait_object_sync_complete(&object_id_2)\n    .await\n    .unwrap();\n\n  // client1 open the collab with object_id_2\n  device_1\n    .open_collab(workspace_id, object_id_2, collab_type)\n    .await;\n  assert_client_collab_within_secs(\n    &mut device_1,\n    &object_id_2,\n    \"name\",\n    json!({\n      \"name\": \"object 2\"\n    }),\n    60,\n  )\n  .await;\n\n  // client2 open the collab with object_id_1\n  device_2\n    .open_collab(workspace_id, object_id_1, collab_type)\n    .await;\n  assert_client_collab_within_secs(\n    &mut device_2,\n    &object_id_1,\n    \"name\",\n    json!({\n      \"name\": \"object 1\"\n    }),\n    60,\n  )\n  .await;\n}\n\n#[tokio::test]\nasync fn edit_document_with_both_clients_offline_then_online_sync_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // add client 2 as a member of the workspace\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n  client_1.disconnect().await;\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  client_2.disconnect().await;\n\n  for i in 0..10 {\n    if i % 2 == 0 {\n      client_1\n        .insert_into(&object_id, &i.to_string(), format!(\"Task {}\", i))\n        .await;\n    } else {\n      client_2\n        .insert_into(&object_id, &i.to_string(), format!(\"Task {}\", i))\n        .await;\n    }\n  }\n\n  tokio::join!(client_1.reconnect(), client_2.reconnect());\n  let (left, right) = tokio::join!(\n    client_1.wait_object_sync_complete(&object_id),\n    client_2.wait_object_sync_complete(&object_id)\n  );\n  assert!(left.is_ok());\n  assert!(right.is_ok());\n\n  let expected_json = json!({\n    \"0\": \"Task 0\",\n    \"1\": \"Task 1\",\n    \"2\": \"Task 2\",\n    \"3\": \"Task 3\",\n    \"4\": \"Task 4\",\n    \"5\": \"Task 5\",\n    \"6\": \"Task 6\",\n    \"7\": \"Task 7\",\n    \"8\": \"Task 8\",\n    \"9\": \"Task 9\"\n  });\n  assert_client_collab_include_value(&mut client_1, &object_id, expected_json.clone())\n    .await\n    .unwrap();\n  assert_client_collab_include_value(&mut client_2, &object_id, expected_json.clone())\n    .await\n    .unwrap();\n}\n\n#[cfg(feature = \"sync-v2\")]\n#[tokio::test]\nasync fn sync_new_documents_created_when_offline_test() {\n  use tokio::time::*;\n  const TIMEOUT: Duration = Duration::from_secs(5);\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n\n  // add client 2 as a member of the workspace\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n  timeout(TIMEOUT, client_1.disconnect())\n    .await\n    .expect(\"first disconnect\");\n  sleep(Duration::from_secs(1)).await;\n\n  // on client 2: create some new collabs while client 1 is offline\n  let mut object_ids = Vec::new();\n  for _ in 0..5 {\n    let object_id = client_2\n      .create_and_edit_collab(workspace_id, collab_type)\n      .await;\n    client_2.insert_into(&object_id, \"key\", \"value\").await;\n    client_2\n      .wait_object_sync_complete(&object_id)\n      .await\n      .unwrap();\n    object_ids.push(object_id);\n  }\n\n  // connect client 1 again and wait a while for sync to complete without asking collabs explicitly\n  timeout(TIMEOUT, client_1.reconnect())\n    .await\n    .expect(\"reconnect\");\n  sleep(Duration::from_secs(5)).await;\n\n  // disconnect client 1 again and check if collabs from client 2 were synced\n  timeout(TIMEOUT, client_1.disconnect())\n    .await\n    .expect(\"second disconnect\");\n  let expected = json!({\"key\":\"value\"});\n  for object_id in object_ids {\n    client_1\n      .open_collab(workspace_id, object_id, collab_type)\n      .await;\n\n    assert_client_collab_include_value(&mut client_1, &object_id, expected.clone())\n      .await\n      .unwrap();\n  }\n}\n"
  },
  {
    "path": "tests/collab/permission_test.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse assert_json_diff::{assert_json_eq, assert_json_include};\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\nuse client_api_test::{\n  assert_client_collab_include_value, assert_client_collab_value, assert_client_collab_within_secs,\n  assert_server_collab, TestClient,\n};\nuse database_entity::dto::AFRole;\n\nuse crate::collab::util::generate_random_string;\n\n#[tokio::test]\nasync fn recv_updates_without_permission_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  // Edit the collab from client 1 and then the server will broadcast to client 2. But the client 2\n  // is not the member of the collab, so the client 2 will not receive the update.\n  client_1.insert_into(&object_id, \"name\", \"AppFlowy\").await;\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_client_collab_value(&mut client_2, &object_id, json!({}))\n    .await\n    .unwrap();\n}\n\n// #[tokio::test]\n// async fn recv_remote_updates_with_readonly_permission_test() {\n//   let collab_type = CollabType::Unknown;\n//   let mut client_1 = TestClient::new_user().await;\n//   let mut client_2 = TestClient::new_user().await;\n//\n//   let workspace_id = client_1.workspace_id().await;\n//   let object_id = client_1\n//     .create_and_edit_collab(workspace_id, collab_type.clone())\n//     .await;\n//\n//   // Add client 2 as the member of the collab then the client 2 will receive the update.\n//   client_1\n//     .add_collab_member(\n//       &workspace_id,\n//       &object_id,\n//       &client_2,\n//       AFAccessLevel::ReadOnly,\n//     )\n//     .await;\n//\n//   client_2\n//     .open_collab(workspace_id, object_id, collab_type.clone())\n//     .await;\n//\n//   // Edit the collab from client 1 and then the server will broadcast to client 2\n//   client_1\n//     .collabs\n//     .get_mut(&object_id)\n//     .unwrap()\n//     .collab\n//     .write().await\n//     .insert(\"name\", \"AppFlowy\");\n//   client_1\n//     .wait_object_sync_complete(&object_id)\n//     .await\n//     .unwrap();\n//\n//   let expected = json!({\n//     \"name\": \"AppFlowy\"\n//   });\n//   assert_client_collab_within_secs(&mut client_2, &object_id, \"name\", expected.clone(), 60).await;\n//   assert_server_collab(\n//     &workspace_id,\n//     &mut client_1.api_client,\n//     &object_id,\n//     &collab_type,\n//     10,\n//     expected,\n//   )\n//   .await\n//   .unwrap();\n// }\n\n// #[tokio::test]\n// async fn init_sync_with_readonly_permission_test() {\n//   let collab_type = CollabType::Unknown;\n//   let mut client_1 = TestClient::new_user().await;\n//   let mut client_2 = TestClient::new_user().await;\n//\n//   let workspace_id = client_1.workspace_id().await;\n//   let object_id = client_1\n//     .create_and_edit_collab(workspace_id, collab_type.clone())\n//     .await;\n//   client_1\n//     .collabs\n//     .get_mut(&object_id)\n//     .unwrap()\n//     .collab\n//     .write().await\n//     .insert(\"name\", \"AppFlowy\");\n//   client_1\n//     .wait_object_sync_complete(&object_id)\n//     .await\n//     .unwrap();\n//   sleep(Duration::from_secs(2)).await;\n//\n//   //\n//   let expected = json!({\n//     \"name\": \"AppFlowy\"\n//   });\n//   assert_server_collab(\n//     &workspace_id,\n//     &mut client_1.api_client,\n//     &object_id,\n//     &collab_type,\n//     10,\n//     expected.clone(),\n//   )\n//   .await\n//   .unwrap();\n//\n//   // Add client 2 as the member of the collab with readonly permission.\n//   // client 2 can pull the latest updates via the init sync. But it's not allowed to send local changes.\n//   client_1\n//     .add_collab_member(\n//       &workspace_id,\n//       &object_id,\n//       &client_2,\n//       AFAccessLevel::ReadOnly,\n//     )\n//     .await;\n//   client_2\n//     .open_collab(workspace_id, object_id, collab_type.clone())\n//     .await;\n//   assert_client_collab_include_value(&mut client_2, &object_id, expected)\n//     .await\n//     .unwrap();\n// }\n\n#[tokio::test]\nasync fn edit_collab_with_readonly_permission_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // Add client 2 as the member of the collab then the client 2 will receive the update.\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Guest)\n    .await\n    .unwrap();\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  // client 2 edit the collab and then the server will reject the update which mean the\n  // collab in the server will not be updated.\n  client_2.insert_into(&object_id, \"name\", \"AppFlowy\").await;\n  assert_client_collab_include_value(\n    &mut client_2,\n    &object_id,\n    json!({\n      \"name\": \"AppFlowy\"\n    }),\n  )\n  .await\n  .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    5,\n    json!({}),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn edit_collab_with_read_and_write_permission_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // Add client 2 as the member of the collab then the client 2 will receive the update.\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  // client 2 edit the collab and then the server will broadcast the update\n  client_2.insert_into(&object_id, \"name\", \"AppFlowy\").await;\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  let expected = json!({\n    \"name\": \"AppFlowy\"\n  });\n  assert_client_collab_include_value(&mut client_2, &object_id, expected.clone())\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    5,\n    expected,\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn edit_collab_with_full_access_permission_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // Add client 2 as the member of the collab then the client 2 will receive the update.\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  // client 2 edit the collab and then the server will broadcast the update\n  client_2.insert_into(&object_id, \"name\", \"AppFlowy\").await;\n\n  let expected = json!({\n    \"name\": \"AppFlowy\"\n  });\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  assert_client_collab_within_secs(&mut client_2, &object_id, \"name\", expected.clone(), 30).await;\n\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    5,\n    expected,\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn edit_collab_with_full_access_then_readonly_permission() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let mut client_2 = TestClient::new_user().await;\n\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // Add client 2 as the member of the collab then the client 2 will receive the update.\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  // client 2 edit the collab and then the server will broadcast the update\n  {\n    client_2\n      .open_collab(workspace_id, object_id, collab_type)\n      .await;\n    client_2\n      .insert_into(&object_id, \"title\", \"hello world\")\n      .await;\n    client_2\n      .wait_object_sync_complete(&object_id)\n      .await\n      .unwrap();\n  }\n\n  // update the permission from full access to readonly, then the server will reject the subsequent\n  // updates generated by client 2\n  {\n    client_1\n      .try_update_workspace_member(&workspace_id, &client_2, AFRole::Guest)\n      .await\n      .unwrap();\n    client_2\n      .insert_into(&object_id, \"subtitle\", \"Writing Rust, fun\")\n      .await;\n  }\n\n  assert_client_collab_include_value(\n    &mut client_2,\n    &object_id,\n    json!({\n      \"title\": \"hello world\",\n      \"subtitle\": \"Writing Rust, fun\"\n    }),\n  )\n  .await\n  .unwrap();\n  assert_server_collab(\n    workspace_id,\n    &mut client_1.api_client,\n    object_id,\n    &collab_type,\n    5,\n    json!({\n      \"title\": \"hello world\"\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn multiple_user_with_read_and_write_permission_edit_same_collab_test() {\n  let mut tasks = Vec::new();\n  let mut owner = TestClient::new_user().await;\n  let object_id = Uuid::new_v4();\n  let collab_type = CollabType::Unknown;\n  let workspace_id = owner.workspace_id().await;\n  owner\n    .create_and_edit_collab_with_data(object_id, workspace_id, collab_type, None, true)\n    .await;\n\n  let arc_owner = Arc::new(owner);\n\n  // simulate multiple users edit the same collab. All of them have read and write permission\n  for i in 0..3 {\n    let owner = arc_owner.clone();\n    let task = tokio::spawn(async move {\n      let mut new_member = TestClient::new_user().await;\n      // sleep 2 secs to make sure it do not trigger register user too fast in gotrue\n      sleep(Duration::from_secs(i % 3)).await;\n\n      owner\n        .invite_and_accepted_workspace_member(&workspace_id, &new_member, AFRole::Member)\n        .await\n        .unwrap();\n\n      new_member\n        .open_collab(workspace_id, object_id, collab_type)\n        .await;\n\n      // generate random string and insert it to the collab\n      let random_str = generate_random_string(200);\n      new_member\n        .insert_into(&object_id, &i.to_string(), random_str.clone())\n        .await;\n      new_member\n        .wait_object_sync_complete(&object_id)\n        .await\n        .unwrap();\n      (random_str, new_member)\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  let mut expected_json = HashMap::new();\n  let mut clients = vec![];\n  for (index, result) in results.into_iter().enumerate() {\n    let (s, client) = result.unwrap();\n    clients.push(client);\n    expected_json.insert(index.to_string(), s);\n  }\n\n  // wait 5 seconds to make sure all the server broadcast the updates to all the clients\n  sleep(Duration::from_secs(5)).await;\n\n  // all the clients should have the same collab object\n  assert_json_eq!(\n    json!(expected_json),\n    (*arc_owner\n      .collabs\n      .get(&object_id)\n      .unwrap()\n      .collab\n      .write()\n      .await)\n      .to_json_value()\n  );\n\n  for client in clients {\n    let expected = (*client.collabs.get(&object_id).unwrap().collab.read().await).to_json_value();\n    assert_json_include!(\n      actual: json!(expected_json),\n      expected: expected\n    );\n  }\n}\n\n#[tokio::test]\nasync fn multiple_user_with_read_only_permission_edit_same_collab_test() {\n  let mut tasks = Vec::new();\n  let mut owner = TestClient::new_user().await;\n  let collab_type = CollabType::Unknown;\n  let workspace_id = owner.workspace_id().await;\n  let object_id = owner\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  let arc_owner = Arc::new(owner);\n\n  for i in 0..5 {\n    let owner = arc_owner.clone();\n    let task = tokio::spawn(async move {\n      let mut new_user = TestClient::new_user().await;\n      // sleep 2 secs to make sure it do not trigger register user too fast in gotrue\n      sleep(Duration::from_secs(i % 2)).await;\n      owner\n        .invite_and_accepted_workspace_member(&workspace_id, &new_user, AFRole::Guest)\n        .await\n        .unwrap();\n\n      new_user\n        .open_collab(workspace_id, object_id, collab_type)\n        .await;\n\n      let random_str = generate_random_string(200);\n      new_user\n        .insert_into(&object_id, &i.to_string(), random_str.clone())\n        .await;\n      // wait 3 seconds to let the client try to send the update to the server\n      // can't use want_object_sync_complete because the client do not have permission to send the update\n      sleep(Duration::from_secs(3)).await;\n      (random_str, new_user)\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  let mut expected_client_json = HashMap::new();\n  let mut clients = vec![];\n\n  for (index, result) in results.into_iter().enumerate() {\n    let (s, client) = result.unwrap();\n    clients.push(client);\n    expected_client_json.insert(index.to_string(), s);\n  }\n\n  // Each client should have their local changes (since they can edit locally)\n  for client in clients {\n    let value = (*client.collabs.get(&object_id).unwrap().collab.read().await).to_json_value();\n    assert_json_include!(\n      actual: value,\n      expected: json!(expected_client_json)\n    );\n  }\n\n  // The server collab should remain empty since read-only users cannot modify it\n  let server_value = (*arc_owner\n    .collabs\n    .get(&object_id)\n    .unwrap()\n    .collab\n    .read()\n    .await)\n    .to_json_value();\n  assert_json_eq!(expected_client_json, server_value);\n}\n"
  },
  {
    "path": "tests/collab/single_device_edit.rs",
    "content": "use std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::Duration;\n\nuse assert_json_diff::assert_json_eq;\nuse client_api::entity::AFRole;\nuse collab::core::origin::CollabOrigin;\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\nuse crate::collab::util::{\n  generate_random_bytes, generate_random_string, make_collab_with_key_value,\n};\nuse client_api_test::*;\nuse collab_rt_entity::{CollabMessage, RealtimeMessage, UpdateSync, MAXIMUM_REALTIME_MESSAGE_SIZE};\n#[tokio::test]\nasync fn realtime_write_single_collab_test() {\n  let collab_type = CollabType::Unknown;\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = test_client\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // Edit the collab\n  for i in 0..=5 {\n    test_client\n      .insert_into(&object_id, &i.to_string(), i.to_string())\n      .await;\n  }\n\n  let expected_json = json!( {\n    \"0\": \"0\",\n    \"1\": \"1\",\n    \"2\": \"2\",\n    \"3\": \"3\",\n  });\n  test_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    expected_json,\n  )\n  .await\n  .unwrap();\n}\n#[tokio::test]\nasync fn collab_write_small_chunk_of_data_test() {\n  let collab_type = CollabType::Unknown;\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n\n  // Calling the open_collab function directly will create the collab object in the plugin.\n  // The [CollabStoragePlugin] plugin try to get the collab object from the database, but it doesn't exist.\n  // So the plugin will create the collab object.\n  test_client\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  let mut expected_json = HashMap::new();\n\n  // Edit the collab\n  for i in 0..=20 {\n    test_client\n      .insert_into(&object_id, &i.to_string(), i.to_string())\n      .await;\n    expected_json.insert(i.to_string(), i.to_string());\n  }\n\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    json!(expected_json),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn collab_write_big_chunk_of_data_test() {\n  let collab_type = CollabType::Unknown;\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n\n  test_client\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  let s = generate_random_string(1000);\n  test_client.insert_into(&object_id, \"text\", s.clone()).await;\n\n  test_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    json!({\n      \"text\": s\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn write_big_chunk_data_init_sync_test() {\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n  let big_text = generate_random_string(MAXIMUM_REALTIME_MESSAGE_SIZE / 2);\n  let collab_type = CollabType::Unknown;\n  let doc_state = make_collab_with_key_value(&object_id, \"text\", big_text.clone());\n\n  // the big doc_state will force the init_sync using the http request.\n  // It will trigger the POST_REALTIME_MESSAGE_STREAM_HANDLER to handle the request.\n  test_client\n    .open_collab_with_doc_state(workspace_id, object_id, collab_type, doc_state)\n    .await;\n  test_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    json!({\n      \"text\": big_text\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn realtime_write_multiple_collab_test() {\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let mut object_ids = vec![];\n  for _ in 0..5 {\n    let collab_type = CollabType::Unknown;\n\n    let object_id = test_client\n      .create_and_edit_collab(workspace_id, collab_type)\n      .await;\n    for i in 0..=5 {\n      test_client\n        .insert_into(&object_id, &i.to_string(), i.to_string())\n        .await;\n    }\n\n    test_client\n      .wait_object_sync_complete(&object_id)\n      .await\n      .unwrap();\n    object_ids.push(object_id);\n  }\n\n  // Wait for the messages to be sent\n  for object_id in object_ids {\n    assert_server_collab(\n      workspace_id,\n      &mut test_client.api_client,\n      object_id,\n      &CollabType::Document,\n      10,\n      json!( {\n        \"0\": \"0\",\n        \"1\": \"1\",\n        \"2\": \"2\",\n        \"3\": \"3\",\n        \"4\": \"4\",\n        \"5\": \"5\",\n      }),\n    )\n    .await\n    .unwrap();\n  }\n}\n\n#[tokio::test]\nasync fn second_connect_override_first_connect_test() {\n  // Different TestClient with same device connect, the last one will\n  // take over the connection.\n  let collab_type = CollabType::Unknown;\n  let mut client = TestClient::new_user().await;\n  let workspace_id = client.workspace_id().await;\n\n  let object_id = client\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  client.insert_into(&object_id, \"1\", \"a\").await;\n\n  // Sleep one second for the doc observer the update. Otherwise, the\n  // sync complete might be called before the update being schedule\n  sleep(Duration::from_secs(1)).await;\n  client.wait_object_sync_complete(&object_id).await.unwrap();\n\n  // the new_client connect with same device_id, so it will replace the existing client\n  // in the server. Which means the old client will not receive updates.\n  let mut new_client =\n    TestClient::new_with_device_id(&client.device_id, client.user.clone(), true).await;\n  new_client\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n  new_client.insert_into(&object_id, \"2\", \"b\").await;\n  new_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  assert_client_collab_include_value(\n    &mut new_client,\n    &object_id,\n    json!({\n      \"1\": \"a\",\n      \"2\": \"b\"\n    }),\n  )\n  .await\n  .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut new_client.api_client,\n    object_id,\n    &collab_type,\n    60,\n    json!({\n      \"1\": \"a\",\n      \"2\": \"b\",\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn same_device_multiple_connect_in_order_test() {\n  let collab_type = CollabType::Unknown;\n  let mut old_client = TestClient::new_user().await;\n  let workspace_id = old_client.workspace_id().await;\n\n  let object_id = old_client\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n  // simulate client try to connect the websocket server by three times\n  // each connect alter the document\n  for i in 0..3 {\n    let mut new_client =\n      TestClient::new_with_device_id(&old_client.device_id, old_client.user.clone(), true).await;\n    new_client\n      .open_collab(workspace_id, object_id, collab_type)\n      .await;\n    new_client.insert_into(&object_id, &i.to_string(), i).await;\n    sleep(Duration::from_millis(500)).await;\n    new_client\n      .wait_object_sync_complete(&object_id)\n      .await\n      .unwrap();\n  }\n\n  assert_server_collab(\n    workspace_id,\n    &mut old_client.api_client,\n    object_id,\n    &collab_type,\n    10,\n    json!({\"0\":0,\"1\":1,\"2\":2}),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn two_direction_peer_sync_test() {\n  let collab_type = CollabType::Unknown;\n\n  let mut client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n  let object_id = client_1\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  let mut client_2 = TestClient::new_user().await;\n  // Before the client_2 want to edit the collab object, it needs to become a member of the collab\n  // Otherwise, the server will reject the edit request\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  client_2\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  client_1.insert_into(&object_id, \"name\", \"AppFlowy\").await;\n  client_1\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  client_2\n    .insert_into(\n      &object_id,\n      \"support platform\",\n      \"macOS, Windows, Linux, iOS, Android\",\n    )\n    .await;\n  client_2\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n\n  let expected_json = json!({\n    \"name\": \"AppFlowy\",\n    \"support platform\": \"macOS, Windows, Linux, iOS, Android\"\n  });\n  assert_client_collab_include_value(&mut client_1, &object_id, expected_json.clone())\n    .await\n    .unwrap();\n  assert_client_collab_include_value(&mut client_2, &object_id, expected_json.clone())\n    .await\n    .unwrap();\n}\n\n#[tokio::test]\nasync fn multiple_collab_edit_test() {\n  let collab_type = CollabType::Unknown;\n  let mut client_1 = TestClient::new_user().await;\n  let workspace_id_1 = client_1.workspace_id().await;\n  let object_id_1 = client_1\n    .create_and_edit_collab(workspace_id_1, collab_type)\n    .await;\n\n  let mut client_2 = TestClient::new_user().await;\n  let workspace_id_2 = client_2.workspace_id().await;\n  let object_id_2 = client_2\n    .create_and_edit_collab(workspace_id_2, collab_type)\n    .await;\n\n  client_1\n    .insert_into(&object_id_1, \"title\", \"I am client 1\")\n    .await;\n  client_1\n    .wait_object_sync_complete(&object_id_1)\n    .await\n    .unwrap();\n\n  client_2\n    .insert_into(&object_id_2, \"title\", \"I am client 2\")\n    .await;\n  client_2\n    .wait_object_sync_complete(&object_id_2)\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id_1,\n    &mut client_1.api_client,\n    object_id_1,\n    &collab_type,\n    10,\n    json!( {\n      \"title\": \"I am client 1\"\n    }),\n  )\n  .await\n  .unwrap();\n\n  assert_server_collab(\n    workspace_id_2,\n    &mut client_2.api_client,\n    object_id_2,\n    &collab_type,\n    10,\n    json!( {\n      \"title\": \"I am client 2\"\n    }),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn simulate_multiple_user_edit_collab_test() {\n  let mut tasks = Vec::new();\n  for _i in 0..5 {\n    let task = tokio::spawn(async move {\n      let mut new_user = TestClient::new_user().await;\n      let collab_type = CollabType::Unknown;\n      let workspace_id = new_user.workspace_id().await;\n      let object_id = Uuid::new_v4();\n\n      new_user\n        .open_collab(workspace_id, object_id, collab_type)\n        .await;\n\n      let random_str = generate_random_string(200);\n      new_user\n        .insert_into(&object_id, \"string\", random_str.clone())\n        .await;\n      let expected_json = json!({\n        \"string\": random_str\n      });\n\n      new_user\n        .wait_object_sync_complete(&object_id)\n        .await\n        .unwrap();\n\n      let json = (*new_user\n        .collabs\n        .get(&object_id)\n        .unwrap()\n        .collab\n        .read()\n        .await)\n        .to_json_value();\n\n      (expected_json, json)\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  for result in results {\n    let (expected_json, json) = result.unwrap();\n    assert_json_eq!(expected_json, json);\n  }\n}\n\n#[tokio::test]\nasync fn post_realtime_message_test() {\n  let mut tasks = Vec::new();\n  let big_text = generate_random_string(64 * 1024);\n\n  for _i in 0..5 {\n    let cloned_text = big_text.clone();\n    let task = tokio::spawn(async move {\n      let mut new_user = TestClient::new_user().await;\n      // sleep 2 secs to make sure it do not trigger register user too fast in gotrue\n      sleep(Duration::from_secs(2)).await;\n\n      let object_id = Uuid::new_v4();\n      let workspace_id = new_user.workspace_id().await;\n      let doc_state = make_collab_with_key_value(&object_id, \"text\", cloned_text);\n      // the big doc_state will force the init_sync using the http request.\n      // It will trigger the POST_REALTIME_MESSAGE_STREAM_HANDLER to handle the request.\n      new_user\n        .open_collab_with_doc_state(workspace_id, object_id, CollabType::Unknown, doc_state)\n        .await;\n\n      new_user\n        .wait_object_sync_complete(&object_id)\n        .await\n        .unwrap();\n      (new_user, object_id, workspace_id)\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  for result in results.into_iter() {\n    let (mut client, object_id, workspace_id) = result.unwrap();\n    assert_server_collab(\n      workspace_id,\n      &mut client.api_client,\n      object_id,\n      &CollabType::Document,\n      10,\n      json!({\n        \"text\": big_text\n      }),\n    )\n    .await\n    .unwrap();\n\n    drop(client);\n  }\n}\n\n#[tokio::test]\nasync fn post_realtime_message_without_ws_connect_test() {\n  let client = Arc::new(TestClient::new_user_without_ws_conn().await);\n  let mut handles = vec![];\n\n  // try to post 10 realtime message without connect to the websocket server.\n  for _ in 0..10 {\n    let cloned_client = client.clone();\n    let handle = tokio::spawn(async move {\n      let message = RealtimeMessage::Collab(CollabMessage::ClientUpdateSync(UpdateSync::new(\n        CollabOrigin::Empty,\n        uuid::Uuid::new_v4().to_string(),\n        generate_random_bytes(1024),\n        1,\n      )))\n      .encode()\n      .unwrap();\n      cloned_client.post_realtime_binary(message).await.unwrap();\n    });\n    handles.push(handle);\n  }\n  for result in futures::future::join_all(handles).await {\n    result.unwrap();\n  }\n}\n\n#[tokio::test]\nasync fn post_realtime_message_with_ws_connect_test() {\n  let client = Arc::new(TestClient::new_user().await);\n  let message = RealtimeMessage::Collab(CollabMessage::ClientUpdateSync(UpdateSync::new(\n    CollabOrigin::Empty,\n    uuid::Uuid::new_v4().to_string(),\n    generate_random_bytes(1024),\n    1,\n  )))\n  .encode()\n  .unwrap();\n  client.post_realtime_binary(message).await.unwrap();\n}\n\n#[tokio::test]\nasync fn simulate_5_offline_user_connect_and_then_sync_document_test() {\n  let text = generate_random_string(1024);\n  let mut tasks = Vec::new();\n  for i in 0..5 {\n    let cloned_text = text.clone();\n    let task = tokio::spawn(async move {\n      let mut new_user = TestClient::new_user_without_ws_conn().await;\n      // sleep to make sure it do not trigger register user too fast in gotrue\n      sleep(Duration::from_secs(i % 5)).await;\n\n      let object_id = Uuid::new_v4();\n      let workspace_id = new_user.workspace_id().await;\n      let doc_state = make_collab_with_key_value(&object_id, \"text\", cloned_text);\n      new_user\n        .open_collab_with_doc_state(workspace_id, object_id, CollabType::Unknown, doc_state)\n        .await;\n      (new_user, object_id)\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  let mut tasks = Vec::new();\n  for result in results.into_iter() {\n    let task = tokio::spawn(async move {\n      let (client, object_id) = result.unwrap();\n      client.reconnect().await;\n      client.wait_object_sync_complete(&object_id).await.unwrap();\n\n      for i in 0..100 {\n        client\n          .insert_into(&object_id, &i.to_string(), i.to_string())\n          .await;\n        sleep(Duration::from_millis(60)).await;\n      }\n      client.wait_object_sync_complete(&object_id).await.unwrap();\n    });\n    tasks.push(task);\n  }\n  let results = futures::future::join_all(tasks).await;\n  for result in results {\n    result.unwrap()\n  }\n}\n\n#[tokio::test]\nasync fn offline_and_then_sync_through_http_request() {\n  let mut test_client = TestClient::new_user().await;\n  let object_id = Uuid::new_v4();\n  let workspace_id = test_client.workspace_id().await;\n  let doc_state = make_collab_with_key_value(&object_id, \"1\", \"\".to_string());\n  test_client\n    .open_collab_with_doc_state(workspace_id, object_id, CollabType::Unknown, doc_state)\n    .await;\n\n  // Verify server hasn't received small text update while offline\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &CollabType::Unknown,\n    10,\n    json!({\"1\":\"\"}),\n  )\n  .await\n  .unwrap();\n\n  test_client.disconnect().await;\n\n  // First insertion - small text\n  let small_text = generate_random_string(100);\n  test_client\n    .insert_into(&object_id, \"1\", small_text.clone())\n    .await;\n\n  // Sync small text changes\n  let encode_collab = test_client\n    .collabs\n    .get(&object_id)\n    .unwrap()\n    .encode_collab()\n    .await;\n  test_client\n    .api_client\n    .collab_full_sync(\n      &workspace_id,\n      &object_id,\n      CollabType::Unknown,\n      encode_collab.doc_state.to_vec(),\n      encode_collab.state_vector.to_vec(),\n    )\n    .await\n    .unwrap();\n\n  // Verify server still has only small text\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &CollabType::Unknown,\n    10,\n    json!({\"1\": small_text.clone()}),\n  )\n  .await\n  .unwrap();\n\n  // Second insertion - medium text\n  let medium_text = generate_random_string(200);\n  test_client\n    .insert_into(&object_id, \"2\", medium_text.clone())\n    .await;\n\n  // Sync medium text changes\n  let encode_collab = test_client\n    .collabs\n    .get(&object_id)\n    .unwrap()\n    .encode_collab()\n    .await;\n  test_client\n    .api_client\n    .collab_full_sync(\n      &workspace_id,\n      &object_id,\n      CollabType::Unknown,\n      encode_collab.doc_state.to_vec(),\n      encode_collab.state_vector.to_vec(),\n    )\n    .await\n    .unwrap();\n\n  assert_client_collab_value(\n    &mut test_client,\n    &object_id,\n    json!({\"1\": small_text, \"2\": medium_text}),\n  )\n  .await\n  .unwrap();\n\n  // Verify medium text was synced\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &CollabType::Unknown,\n    10,\n    json!({\"1\": small_text, \"2\": medium_text}),\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn insert_text_through_http_post_request() {\n  let mut test_client = TestClient::new_user().await;\n  let object_id = Uuid::new_v4();\n  let workspace_id = test_client.workspace_id().await;\n  let doc_state = make_collab_with_key_value(&object_id, \"1\", \"\".to_string());\n  test_client\n    .open_collab_with_doc_state(workspace_id, object_id, CollabType::Unknown, doc_state)\n    .await;\n  test_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  test_client.disconnect().await;\n\n  let mut final_text = HashMap::new();\n  for i in 0..1000 {\n    let key = i.to_string();\n    let text = generate_random_string(10);\n    test_client\n      .insert_into(&object_id, &key, text.clone())\n      .await;\n    final_text.insert(key, text);\n    if i % 100 == 0 {\n      tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;\n    }\n  }\n\n  let encode_collab = test_client\n    .collabs\n    .get(&object_id)\n    .unwrap()\n    .encode_collab()\n    .await;\n  test_client\n    .api_client\n    .collab_full_sync(\n      &workspace_id,\n      &object_id,\n      CollabType::Unknown,\n      encode_collab.doc_state.to_vec(),\n      encode_collab.state_vector.to_vec(),\n    )\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut test_client.api_client,\n    object_id,\n    &CollabType::Unknown,\n    10,\n    json!(final_text),\n  )\n  .await\n  .unwrap();\n}\n"
  },
  {
    "path": "tests/collab/storage_test.rs",
    "content": "use app_error::ErrorCode;\nuse appflowy_collaborate::collab::cache::mem_cache::CollabMemCache;\nuse appflowy_collaborate::CollabMetrics;\nuse client_api_test::*;\nuse collab::core::transaction::DocTransactionExtension;\nuse collab::entity::EncodedCollab;\nuse collab::preclude::{Doc, Transact};\nuse collab_entity::CollabType;\nuse database_entity::dto::{\n  CreateCollabParams, DeleteCollabParams, QueryCollab, QueryCollabParams, QueryCollabResult,\n};\nuse infra::thread_pool::{ThreadPoolNoAbort, ThreadPoolNoAbortBuilder};\nuse sqlx::types::Uuid;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse workspace_template::document::getting_started::GettingStartedTemplate;\nuse workspace_template::WorkspaceTemplateBuilder;\n\nuse crate::collab::util::{redis_connection_manager, test_encode_collab_v1};\n\n#[tokio::test]\nasync fn success_insert_collab_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let object_id = Uuid::new_v4();\n  let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\");\n  c.create_collab(CreateCollabParams {\n    object_id,\n    collab_type: CollabType::Unknown,\n    workspace_id,\n    encoded_collab_v1: encode_collab.encode_to_bytes().unwrap(),\n  })\n  .await\n  .unwrap();\n\n  let doc_state = c\n    .get_collab(QueryCollabParams::new(\n      object_id,\n      CollabType::Document,\n      workspace_id,\n    ))\n    .await\n    .unwrap()\n    .encode_collab\n    .doc_state;\n\n  assert_eq!(doc_state, encode_collab.doc_state);\n}\n\n#[tokio::test]\nasync fn success_batch_get_collab_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let queries = vec![\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n  ];\n\n  let mut expected_results = HashMap::new();\n  for query in queries.iter() {\n    let object_id = query.object_id;\n    let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\")\n      .encode_to_bytes()\n      .unwrap();\n    let collab_type = query.collab_type;\n\n    expected_results.insert(\n      object_id,\n      QueryCollabResult::Success {\n        encode_collab_v1: encode_collab.clone(),\n      },\n    );\n\n    c.create_collab(CreateCollabParams {\n      object_id,\n      encoded_collab_v1: encode_collab.clone(),\n      collab_type,\n      workspace_id,\n    })\n    .await\n    .unwrap();\n  }\n\n  let results = c.batch_get_collab(&workspace_id, queries).await.unwrap().0;\n  for (object_id, result) in expected_results.iter() {\n    assert_eq!(result, results.get(object_id).unwrap());\n  }\n}\n\n#[tokio::test]\nasync fn success_part_batch_get_collab_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let queries = vec![\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n  ];\n\n  let mut expected_results = HashMap::new();\n  for (index, query) in queries.iter().enumerate() {\n    let object_id = query.object_id;\n    let collab_type = query.collab_type;\n    let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\")\n      .encode_to_bytes()\n      .unwrap();\n\n    if index == 1 {\n      expected_results.insert(\n        object_id,\n        QueryCollabResult::Failed {\n          error: format!(\n            \"Record not found:Collab not found for object_id: {}\",\n            object_id\n          ),\n        },\n      );\n    } else {\n      expected_results.insert(\n        object_id,\n        QueryCollabResult::Success {\n          encode_collab_v1: encode_collab.clone(),\n        },\n      );\n      c.create_collab(CreateCollabParams {\n        object_id,\n        encoded_collab_v1: encode_collab.clone(),\n        collab_type,\n        workspace_id,\n      })\n      .await\n      .unwrap();\n    }\n  }\n\n  let results = c.batch_get_collab(&workspace_id, queries).await.unwrap().0;\n  for (object_id, result) in expected_results.iter() {\n    assert_eq!(result, results.get(object_id).unwrap());\n  }\n}\n\n#[tokio::test]\nasync fn success_delete_collab_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let collab_type = CollabType::Unknown;\n  let object_id = Uuid::new_v4();\n  let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\")\n    .encode_to_bytes()\n    .unwrap();\n\n  c.create_collab(CreateCollabParams {\n    object_id,\n    encoded_collab_v1: encode_collab,\n    collab_type,\n    workspace_id,\n  })\n  .await\n  .unwrap();\n\n  c.delete_collab(DeleteCollabParams {\n    object_id,\n    workspace_id,\n  })\n  .await\n  .unwrap();\n\n  // The deletion might take time to propagate through Redis cache, so the test needs to retry\n  // with a timeout to wait for the deletion to be reflected. Let me refactor the test to handle\n  // this properly.\n  let start_time = std::time::Instant::now();\n  let timeout = std::time::Duration::from_secs(10);\n  let retry_interval = std::time::Duration::from_millis(100);\n\n  loop {\n    match c\n      .get_collab(QueryCollabParams::new(object_id, collab_type, workspace_id))\n      .await\n    {\n      Ok(_) => {\n        if start_time.elapsed() > timeout {\n          panic!(\n            \"Timeout: Expected error when getting deleted collab after {}s, object_id: {}\",\n            timeout.as_secs(),\n            object_id\n          );\n        }\n        tokio::time::sleep(retry_interval).await;\n      },\n      Err(error) => {\n        assert_eq!(error.code, ErrorCode::RecordDeleted);\n        break;\n      },\n    }\n  }\n}\n\n#[tokio::test]\nasync fn batch_get_collab_filters_deleted_records_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n\n  // Create 3 collabs\n  let queries = vec![\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n    QueryCollab {\n      object_id: Uuid::new_v4(),\n      collab_type: CollabType::Unknown,\n    },\n  ];\n\n  // Create all collabs\n  for query in queries.iter() {\n    let object_id = query.object_id;\n    let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\")\n      .encode_to_bytes()\n      .unwrap();\n    let collab_type = query.collab_type;\n\n    c.create_collab(CreateCollabParams {\n      object_id,\n      encoded_collab_v1: encode_collab.clone(),\n      collab_type,\n      workspace_id,\n    })\n    .await\n    .unwrap();\n  }\n\n  // Delete the second collab\n  let deleted_object_id = queries[1].object_id;\n  c.delete_collab(DeleteCollabParams {\n    object_id: deleted_object_id,\n    workspace_id,\n  })\n  .await\n  .unwrap();\n\n  // Batch get all collabs\n  let results = c\n    .batch_get_collab(&workspace_id, queries.clone())\n    .await\n    .unwrap()\n    .0;\n\n  // Verify results\n  assert_eq!(results.len(), 3);\n\n  // First and third should be successful\n  assert!(matches!(\n    results.get(&queries[0].object_id).unwrap(),\n    QueryCollabResult::Success { .. }\n  ));\n  assert!(matches!(\n    results.get(&queries[2].object_id).unwrap(),\n    QueryCollabResult::Success { .. }\n  ));\n\n  // Second should be failed with deletion error\n  match results.get(&deleted_object_id).unwrap() {\n    QueryCollabResult::Failed { error } => {\n      assert!(\n        error.contains(\"is deleted\") || error.contains(\"Record not found\"),\n        \"Error message should indicate deletion or not found: {}\",\n        error\n      );\n    },\n    _ => panic!(\"Expected failed result for deleted collab\"),\n  }\n}\n\n#[tokio::test]\nasync fn fail_insert_collab_with_empty_payload_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c).await;\n  let error = c\n    .create_collab(CreateCollabParams {\n      object_id: Uuid::new_v4(),\n      encoded_collab_v1: vec![],\n      collab_type: CollabType::Document,\n      workspace_id,\n    })\n    .await\n    .unwrap_err();\n\n  assert_eq!(error.code, ErrorCode::NoRequiredData);\n}\n\n#[tokio::test]\nasync fn fail_insert_collab_with_invalid_workspace_id_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = Uuid::new_v4();\n  let object_id = Uuid::new_v4();\n  let encode_collab = test_encode_collab_v1(&object_id, \"title\", \"hello world\")\n    .encode_to_bytes()\n    .unwrap();\n  let error = c\n    .create_collab(CreateCollabParams {\n      object_id,\n      encoded_collab_v1: encode_collab,\n      collab_type: CollabType::Unknown,\n      workspace_id,\n    })\n    .await\n    .unwrap_err();\n\n  assert_eq!(error.code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn collab_mem_cache_read_write_test() {\n  let conn = redis_connection_manager().await;\n  let mem_cache = CollabMemCache::new(pool(), conn, CollabMetrics::default().into());\n  let encode_collab = EncodedCollab::new_v1(vec![1, 2, 3], vec![4, 5, 6]);\n\n  let object_id = Uuid::new_v4();\n  let timestamp = chrono::Utc::now().timestamp_millis() as u64;\n  mem_cache\n    .insert_encode_collab_data(\n      &object_id,\n      &encode_collab.encode_to_bytes().unwrap(),\n      timestamp.into(),\n      None,\n    )\n    .await\n    .unwrap();\n\n  let (_, encode_collab_from_cache) = mem_cache.get_encode_collab(&object_id).await.unwrap();\n  assert_eq!(encode_collab_from_cache.state_vector, vec![1, 2, 3]);\n  assert_eq!(encode_collab_from_cache.doc_state, vec![4, 5, 6]);\n}\n\n#[tokio::test]\nasync fn collab_mem_cache_insert_override_test() {\n  let conn = redis_connection_manager().await;\n  let mem_cache = CollabMemCache::new(pool(), conn, CollabMetrics::default().into());\n  let object_id = Uuid::new_v4();\n  let encode_collab = EncodedCollab::new_v1(vec![1, 2, 3], vec![4, 5, 6]);\n  let mut timestamp = chrono::Utc::now().timestamp_millis() as u64;\n  mem_cache\n    .insert_encode_collab_data(\n      &object_id,\n      &encode_collab.encode_to_bytes().unwrap(),\n      timestamp.into(),\n      None,\n    )\n    .await\n    .unwrap();\n\n  // the following insert should not override the previous one because the timestamp is older\n  // than the previous one\n  timestamp -= 100;\n  mem_cache\n    .insert_encode_collab_data(\n      &object_id,\n      &EncodedCollab::new_v1(vec![6, 7, 8], vec![9, 10, 11])\n        .encode_to_bytes()\n        .unwrap(),\n      timestamp.into(),\n      None,\n    )\n    .await\n    .unwrap();\n\n  // check that the previous insert is still in the cache\n  let (_, encode_collab_from_cache) = mem_cache.get_encode_collab(&object_id).await.unwrap();\n  assert_eq!(encode_collab_from_cache.doc_state, encode_collab.doc_state);\n  assert_eq!(encode_collab_from_cache.state_vector, vec![1, 2, 3]);\n  assert_eq!(encode_collab_from_cache.doc_state, vec![4, 5, 6]);\n\n  // the following insert should override the previous one because the timestamp is newer\n  timestamp += 500;\n  mem_cache\n    .insert_encode_collab_data(\n      &object_id,\n      &EncodedCollab::new_v1(vec![12, 13, 14], vec![15, 16, 17])\n        .encode_to_bytes()\n        .unwrap(),\n      timestamp.into(),\n      None,\n    )\n    .await\n    .unwrap();\n\n  // check that the previous insert is overridden\n  let (_, encode_collab_from_cache) = mem_cache.get_encode_collab(&object_id).await.unwrap();\n  assert_eq!(encode_collab_from_cache.doc_state, vec![15, 16, 17]);\n  assert_eq!(encode_collab_from_cache.state_vector, vec![12, 13, 14]);\n}\n\n#[tokio::test]\nasync fn insert_empty_data_test() {\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n\n  // test all collab type\n  for collab_type in [\n    CollabType::Folder,\n    CollabType::Document,\n    CollabType::UserAwareness,\n    CollabType::WorkspaceDatabase,\n    CollabType::Database,\n    CollabType::DatabaseRow,\n  ] {\n    let params = CreateCollabParams {\n      workspace_id,\n      object_id,\n      encoded_collab_v1: vec![],\n      collab_type,\n    };\n    let error = test_client\n      .api_client\n      .create_collab(params)\n      .await\n      .unwrap_err();\n    assert_eq!(error.code, ErrorCode::NoRequiredData);\n  }\n}\n\n#[tokio::test]\nasync fn insert_invalid_data_test() {\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n\n  let doc = Doc::new();\n  let encoded_collab_v1 = doc\n    .transact()\n    .get_encoded_collab_v1()\n    .encode_to_bytes()\n    .unwrap();\n  for collab_type in [\n    CollabType::Folder,\n    CollabType::Document,\n    CollabType::UserAwareness,\n    CollabType::WorkspaceDatabase,\n    CollabType::Database,\n    CollabType::DatabaseRow,\n  ] {\n    let params = CreateCollabParams {\n      workspace_id,\n      object_id,\n      encoded_collab_v1: encoded_collab_v1.clone(),\n      collab_type,\n    };\n    let error = test_client\n      .api_client\n      .create_collab(params)\n      .await\n      .unwrap_err();\n    assert_eq!(\n      error.code,\n      ErrorCode::NoRequiredData,\n      \"collab_type: {:?}\",\n      collab_type\n    );\n  }\n}\n\n#[tokio::test]\nasync fn insert_folder_data_success_test() {\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = Uuid::new_v4();\n  let uid = test_client.uid().await;\n\n  let templates = WorkspaceTemplateBuilder::new(uid, &workspace_id)\n    .with_templates(vec![GettingStartedTemplate])\n    .build()\n    .await\n    .unwrap();\n\n  // 2 spaces, 4 documents, 2 databases, 5 rows\n  assert_eq!(templates.len(), 13);\n\n  for template in templates.into_iter() {\n    let data = template.encoded_collab.encode_to_bytes().unwrap();\n    let params = CreateCollabParams {\n      workspace_id,\n      object_id,\n      encoded_collab_v1: data,\n      collab_type: template.collab_type,\n    };\n    test_client.api_client.create_collab(params).await.unwrap();\n  }\n}\n\nfn pool() -> Arc<ThreadPoolNoAbort> {\n  let thread_pool = ThreadPoolNoAbortBuilder::new()\n    .thread_name(|idx| format!(\"af-collab-worker-{}\", idx))\n    .num_threads(1)\n    .build()\n    .expect(\"Failed to create collab thread pool\");\n  Arc::new(thread_pool)\n}\n"
  },
  {
    "path": "tests/collab/stress_test.rs",
    "content": "use std::sync::Arc;\nuse std::time::Duration;\n\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\nuse super::util::TestScenario;\nuse client_api_test::{assert_server_collab, TestClient};\nuse database_entity::dto::AFRole;\n\n#[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\nasync fn stress_test_run_multiple_text_edits() {\n  const READER_COUNT: usize = 1;\n  let test_scenario = Arc::new(TestScenario::open(\n    \"./tests/collab/asset/automerge-paper.json.gz\",\n  ));\n  // create writer\n  let mut writer = TestClient::new_user().await;\n  sleep(Duration::from_secs(5)).await; // sleep 5 secs to make sure it do not trigger register user too fast in gotrue\n\n  let object_id = Uuid::new_v4();\n  let workspace_id = writer.workspace_id().await;\n\n  writer\n    .open_collab(workspace_id, object_id, CollabType::Unknown)\n    .await;\n\n  // create readers and invite them into the same workspace\n  let mut readers = Vec::with_capacity(READER_COUNT);\n  for _ in 0..READER_COUNT {\n    let mut reader = TestClient::new_user().await;\n    sleep(Duration::from_secs(2)).await; // sleep 2 secs to make sure it do not trigger register user too fast in gotrue\n    writer\n      .invite_and_accepted_workspace_member(&workspace_id, &reader, AFRole::Member)\n      .await\n      .unwrap();\n\n    reader\n      .open_collab(workspace_id, object_id, CollabType::Unknown)\n      .await;\n\n    readers.push(reader);\n  }\n\n  // run test scenario\n  let collab = writer.collabs.get(&object_id).unwrap().collab.clone();\n  let expected = test_scenario.execute(collab, 20_000).await;\n\n  // wait for the writer to complete sync\n  writer.wait_object_sync_complete(&object_id).await.unwrap();\n\n  // wait for the readers to complete sync\n  let mut tasks = Vec::with_capacity(READER_COUNT);\n  for reader in readers.iter() {\n    let fut = reader.wait_object_sync_complete(&object_id);\n    tasks.push(fut);\n  }\n  let results = futures::future::join_all(tasks).await;\n\n  // make sure that the readers are in correct state\n  for res in results {\n    res.unwrap();\n  }\n\n  for mut reader in readers.drain(..) {\n    assert_server_collab(\n      workspace_id,\n      &mut reader.api_client,\n      object_id,\n      &CollabType::Unknown,\n      10,\n      json!({\n        \"text-id\": &expected,\n      }),\n    )\n    .await\n    .unwrap();\n  }\n}\n"
  },
  {
    "path": "tests/collab/util.rs",
    "content": "use std::time::Duration;\n\nuse anyhow::Context;\nuse collab::core::collab::DataSource;\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab::preclude::Collab;\nuse collab_document::blocks::Block;\nuse collab_document::document::Document;\nuse collab_document::document_data::default_document_collab_data;\nuse nanoid::nanoid;\nuse rand::distributions::Alphanumeric;\nuse rand::{thread_rng, Rng};\nuse redis::aio::ConnectionManager;\nuse tokio::time::sleep;\n\n#[allow(dead_code)]\npub fn generate_random_bytes(size: usize) -> Vec<u8> {\n  let s: String = thread_rng()\n    .sample_iter(&Alphanumeric)\n    .take(size)\n    .map(char::from)\n    .collect();\n  s.into_bytes()\n}\n\n#[allow(dead_code)]\npub fn generate_random_string(len: usize) -> String {\n  let rng = thread_rng();\n  rng\n    .sample_iter(&Alphanumeric)\n    .take(len)\n    .map(char::from)\n    .collect()\n}\n\npub fn make_collab_with_key_value(object_id: &Uuid, key: &str, value: String) -> Vec<u8> {\n  let options = CollabOptions::new(object_id.to_string(), default_client_id());\n  let mut collab = Collab::new_with_options(CollabOrigin::Empty, options).unwrap();\n  collab.insert(key, value);\n  collab\n    .encode_collab_v1(|_| Ok::<(), anyhow::Error>(()))\n    .unwrap()\n    .doc_state\n    .to_vec()\n}\n\npub struct TestDocumentEditor {\n  pub document: Document,\n}\n\nimpl TestDocumentEditor {\n  pub(crate) fn clear(&mut self) {\n    let page_id = self.document.get_page_id().unwrap();\n    let blocks = self.document.get_block_children_ids(&page_id);\n    for block in blocks {\n      self.document.delete_block(&block).unwrap();\n    }\n  }\n\n  pub(crate) fn insert_paragraphs(&mut self, paragraphs: Vec<String>) {\n    let page_id = self.document.get_page_id().unwrap();\n    let mut prev_id = \"\".to_string();\n    for paragraph in paragraphs {\n      let block_id = nanoid!(6);\n      let text_id = nanoid!(6);\n      let block = Block {\n        id: block_id.clone(),\n        ty: \"paragraph\".to_owned(),\n        parent: page_id.clone(),\n        children: \"\".to_string(),\n        external_id: Some(text_id.clone()),\n        external_type: Some(\"text\".to_owned()),\n        data: Default::default(),\n      };\n\n      self\n        .document\n        .insert_block(block, Some(prev_id.clone()))\n        .unwrap();\n      prev_id.clone_from(&block_id);\n      self\n        .document\n        .apply_text_delta(&text_id, format!(r#\"[{{\"insert\": \"{}\"}}]\"#, paragraph));\n    }\n  }\n\n  pub fn encode_collab(&self) -> EncodedCollab {\n    self.document.encode_collab().unwrap()\n  }\n}\n\npub fn empty_document_editor(object_id: &Uuid) -> TestDocumentEditor {\n  let object_id = object_id.to_string();\n  let client_id = default_client_id();\n  let doc_state = default_document_collab_data(&object_id, client_id)\n    .unwrap()\n    .doc_state\n    .to_vec();\n  let options = CollabOptions::new(object_id.clone(), client_id)\n    .with_data_source(DataSource::DocStateV1(doc_state));\n  let collab = Collab::new_with_options(CollabOrigin::Empty, options).unwrap();\n  TestDocumentEditor {\n    document: Document::open(collab).unwrap(),\n  }\n}\n\npub fn alex_software_engineer_story() -> Vec<&'static str> {\n  vec![\n    \"Alex is a software programmer who spends his days coding and solving problems.\",\n    \"Outside of work, he enjoys staying active with sports like tennis, basketball, cycling, badminton, and snowboarding.\",\n    \"He learned tennis while living in Singapore and now enjoys playing with his friends on weekends.\",\n    \"Alex had an unforgettable experience trying two diamond slopes in Lake Tahoe, which challenged his snowboarding skills.\",\n    \"He brought his bike with him when he moved to Singapore, excited to explore the city on two wheels.\",\n    \"Although he hasn't ridden his bike in Singapore yet, Alex looks forward to cycling through the city's famous parks and scenic routes.\",\n    \"Alex enjoys the thrill of playing different sports, finding balance between his work and physical activities.\",\n    \"From the adrenaline of snowboarding to the strategic moves on the tennis court, each sport gives him a sense of freedom and excitement.\",\n  ]\n}\n\npub fn snowboarding_in_japan_plan() -> Vec<&'static str> {\n  vec![\n    \"Our trip begins with a flight from American to Tokyo on January 7th.\",\n    \"In Tokyo, we'll spend three days, from February 7th to 10th, exploring the city's tech scene and snowboarding gear shops.\",\n    \"We'll visit popular spots like Shibuya, Shinjuku, and Odaiba before heading to our next destination.\",\n    \"From Tokyo, we fly to Sendai and then travel to Zao Onsen for a 3-day stay from February 10th to 14th.\",\n    \"Zao Onsen is famous for its beautiful snow and the iconic ice trees, which will make for a unique snowboarding experience.\",\n    \"After Zao Onsen, we fly from Sendai to Chitose, then head to Sapporo for a 2-day visit, exploring the city's vibrant atmosphere and winter attractions.\",\n    \"On the next day, we'll spend time at Sapporo Tein, a ski resort that offers great runs and stunning views of the city and the sea.\",\n    \"Then we head to Rusutsu for 5 days, one of the top ski resorts in Japan, known for its deep powder snow and extensive runs.\",\n    \"Finally, we'll fly back to Singapore after experiencing some of the best snowboarding Japan has to offer.\",\n    \"Ski resorts to visit include Niseko (二世谷), Rusutsu (留寿都), Sapporo Tein (札幌和海景), and Zao Onsen Ski Resort (冰树).\",\n  ]\n}\n\npub fn alex_banker_story() -> Vec<&'static str> {\n  vec![\n    \"Alex is a banker who spends most of their time working with numbers.\",\n    \"They don't enjoy sports or physical activities, preferring to relax.\",\n    \"Instead of exercise, Alex finds joy in eating delicious food and\",\n    \"exploring new restaurants. They love trying different cuisines and\",\n    \"discovering new dishes that excite their taste buds.\",\n    \"For Alex, food is a form of relaxation and self-care, a break from\",\n    \"the hectic world of banking. A perfect meal brings them comfort.\",\n    \"Whether dining out or cooking at home, Alex enjoys savoring flavors\",\n    \"and experiencing the joy of good food. It's their favorite way to unwind.\",\n    \"Though they don't share the same passion for sports, Alex finds\",\n    \"happiness in the culinary world, where each new dish is an adventure.\",\n  ]\n}\n\npub fn test_encode_collab_v1(object_id: &Uuid, key: &str, value: &str) -> EncodedCollab {\n  let options = CollabOptions::new(object_id.to_string(), default_client_id());\n  let mut collab = Collab::new_with_options(CollabOrigin::Empty, options).unwrap();\n  collab.insert(key, value);\n  collab\n    .encode_collab_v1(|_| Ok::<(), anyhow::Error>(()))\n    .unwrap()\n}\n\npub async fn redis_client() -> redis::Client {\n  let redis_uri = \"redis://localhost:6379\";\n  redis::Client::open(redis_uri)\n    .context(\"failed to connect to redis\")\n    .unwrap()\n}\n\npub async fn redis_connection_manager() -> ConnectionManager {\n  let mut attempt = 0;\n  let max_attempts = 5;\n  let mut wait_time = 500;\n  loop {\n    match redis_client().await.get_connection_manager().await {\n      Ok(manager) => return manager,\n      Err(err) => {\n        if attempt >= max_attempts {\n          panic!(\"{:?}\", err); // Exceeded maximum attempts, return the last error.\n        }\n        sleep(Duration::from_millis(wait_time)).await;\n        wait_time *= 2;\n        attempt += 1;\n      },\n    }\n  }\n}\n\nuse std::io::{BufReader, Read};\n\nuse collab::preclude::MapExt;\nuse collab_rt_protocol::CollabRef;\nuse flate2::bufread::GzDecoder;\nuse serde::Deserialize;\nuse uuid::Uuid;\nuse yrs::{GetString, Text, TextRef};\n\n/// (position, delete length, insert content).\n#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]\npub struct TestPatch(pub usize, pub usize, pub String);\n\n#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]\npub struct TestTxn {\n  // time: String, // ISO String. Unused.\n  pub patches: Vec<TestPatch>,\n}\n\n#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]\npub struct TestScenario {\n  #[serde(default)]\n  pub using_byte_positions: bool,\n\n  #[serde(rename = \"startContent\")]\n  pub start_content: String,\n  #[serde(rename = \"endContent\")]\n  pub end_content: String,\n\n  pub txns: Vec<TestTxn>,\n}\n\nimpl TestScenario {\n  /// Load the testing data at the specified file. If the filename ends in .gz, it will be\n  /// transparently uncompressed.\n  ///\n  /// This method panics if the file does not exist, or is corrupt. It'd be better to have a try_\n  /// variant of this method, but given this is mostly for benchmarking and testing, I haven't felt\n  /// the need to write that code.\n  pub fn open(fpath: &str) -> TestScenario {\n    // let start = SystemTime::now();\n    // let mut file = File::open(\"benchmark_data/automerge-paper.json.gz\").unwrap();\n    let file = std::fs::File::open(fpath).unwrap();\n\n    let mut reader = BufReader::new(file);\n    // We could pass the GzDecoder straight to serde, but it makes it way slower to parse for\n    // some reason.\n    let mut raw_json = vec![];\n\n    if fpath.ends_with(\".gz\") {\n      let mut reader = GzDecoder::new(reader);\n      reader.read_to_end(&mut raw_json).unwrap();\n    } else {\n      reader.read_to_end(&mut raw_json).unwrap();\n    }\n\n    let data: TestScenario = serde_json::from_reader(raw_json.as_slice()).unwrap();\n    data\n  }\n\n  pub async fn execute(&self, collab: CollabRef, step_count: usize) -> String {\n    let mut i = 0;\n    for t in self.txns.iter().take(step_count) {\n      i += 1;\n      if i % 10_000 == 0 {\n        tracing::trace!(\"Executed {}/{} steps\", i, step_count);\n      }\n      let mut lock = collab.write().await;\n      let collab = lock.borrow_mut();\n      let mut txn = collab.context.transact_mut();\n      let txt = collab.data.get_or_init_text(&mut txn, \"text-id\");\n      for patch in t.patches.iter() {\n        let at = patch.0;\n        let delete = patch.1;\n        let content = patch.2.as_str();\n\n        if delete != 0 {\n          txt.remove_range(&mut txn, at as u32, delete as u32);\n        }\n        if !content.is_empty() {\n          txt.insert(&mut txn, at as u32, content);\n        }\n      }\n    }\n\n    // validate after applying all patches\n    let lock = collab.read().await;\n    let collab = lock.borrow();\n    let txn = collab.context.transact();\n    let txt: TextRef = collab.data.get_with_txn(&txn, \"text-id\").unwrap();\n    txt.get_string(&txn)\n  }\n}\n"
  },
  {
    "path": "tests/collab/web_edit.rs",
    "content": "use client_api::entity::{QueryCollab, QueryCollabParams, UpdateCollabWebParams};\nuse client_api_test::{\n  assert_client_collab_value, assert_server_collab, generate_unique_registered_user, TestClient,\n};\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse yrs::{updates::decoder::Decode, Map, ReadTxn, StateVector, Transact};\n\n#[tokio::test]\nasync fn web_and_native_app_edit_same_collab_test() {\n  let collab_type = CollabType::Unknown;\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let workspace_id = app_client.workspace_id().await;\n  let object_id = app_client\n    .create_and_edit_collab(workspace_id, collab_type)\n    .await;\n\n  // client 1 edit the collab\n  app_client\n    .insert_into(&object_id, \"name\", \"workspace1\")\n    .await;\n  app_client\n    .wait_object_sync_complete(&object_id)\n    .await\n    .unwrap();\n  assert_server_collab(\n    workspace_id,\n    &mut app_client.api_client,\n    object_id,\n    &collab_type,\n    30,\n    json!({\n      \"name\": \"workspace1\"\n    }),\n  )\n  .await\n  .unwrap();\n  // AppFlowy Web does not actually rely on the Rust client, the below is just to emulate\n  // the behaviour of the web frontend's javascript client.\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let collab_doc_state = web_client\n    .api_client\n    .get_collab(QueryCollabParams {\n      workspace_id,\n      inner: QueryCollab {\n        object_id,\n        collab_type,\n      },\n    })\n    .await\n    .unwrap()\n    .encode_collab\n    .doc_state;\n  let web_doc = yrs::Doc::new();\n  let update = yrs::Update::decode_v1(&collab_doc_state).unwrap();\n  web_doc.transact_mut().apply_update(update).unwrap();\n  let doc_data = web_doc.transact().get_map(\"data\").unwrap();\n  {\n    let mut txn = web_doc.transact_mut();\n    doc_data.insert(&mut txn, \"paragraph\", \"content\");\n  }\n  web_client\n    .api_client\n    .update_web_collab(\n      &workspace_id,\n      &object_id,\n      UpdateCollabWebParams {\n        doc_state: web_doc\n          .transact()\n          .encode_state_as_update_v1(&StateVector::default()),\n        collab_type,\n      },\n    )\n    .await\n    .unwrap();\n\n  let expected_json = json!({\n    \"name\": \"workspace1\",\n    \"paragraph\": \"content\",\n  });\n  assert_server_collab(\n    workspace_id,\n    &mut app_client.api_client,\n    object_id,\n    &collab_type,\n    30,\n    expected_json.clone(),\n  )\n  .await\n  .unwrap();\n\n  assert_client_collab_value(&mut app_client, &object_id, expected_json.clone())\n    .await\n    .unwrap();\n}\n"
  },
  {
    "path": "tests/collab_history/document_history.rs",
    "content": "use assert_json_diff::assert_json_include;\nuse client_api_test::TestClient;\nuse collab::core::collab::DataSource;\nuse collab::core::origin::CollabOrigin;\nuse collab::preclude::updates::decoder::Decode;\nuse collab::preclude::updates::encoder::{Encoder, EncoderV2};\nuse collab::preclude::{Collab, ReadTxn, Snapshot, Update};\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse tokio::time::sleep;\n\n#[tokio::test]\nasync fn collab_history_and_snapshot_test() {\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = uuid::Uuid::new_v4().to_string();\n\n  // Using [CollabType::Unknown] for testing purposes.\n  let collab_type = CollabType::Unknown;\n  test_client\n    .create_and_edit_collab_with_data(&object_id, &workspace_id, collab_type, None)\n    .await;\n  test_client\n    .open_collab(&workspace_id, &object_id, collab_type)\n    .await;\n\n  // from the beginning, there should be no snapshots\n  let snapshots = test_client\n    .api_client\n    .get_snapshots(&workspace_id, &object_id, collab_type)\n    .await\n    .unwrap()\n    .items;\n  assert!(snapshots.is_empty());\n\n  // Simulate the client editing the collaboration object. A snapshot is generated if the number of edits\n  // exceeds a specific threshold. By default, [CollabType::Unknown] has a threshold of 10 edits in debug mode.\n  for i in 0..10 {\n    let mut lock = test_client\n      .collabs\n      .get(&object_id)\n      .unwrap()\n      .collab\n      .write()\n      .await;\n    lock.borrow_mut().insert(&i.to_string(), i.to_string());\n    sleep(std::time::Duration::from_millis(1000)).await;\n  }\n  // Wait for the snapshot to be generated.\n  sleep(std::time::Duration::from_secs(10)).await;\n  let snapshots = test_client\n    .api_client\n    .get_snapshots(&workspace_id, &object_id, collab_type)\n    .await\n    .unwrap()\n    .items;\n  assert!(!snapshots.is_empty());\n\n  // Get the latest history\n  let snapshot_info = test_client\n    .api_client\n    .get_latest_history(&workspace_id, &object_id, collab_type)\n    .await\n    .unwrap();\n\n  let full_collab = Collab::new_with_source(\n    CollabOrigin::Empty,\n    &object_id,\n    DataSource::DocStateV2(snapshot_info.history.doc_state),\n    vec![],\n    true,\n  )\n  .unwrap();\n\n  // Collab restored from the history data may not contain all the data. So just compare part of the data.\n  assert_json_include!(\n    actual: full_collab.to_json_value(),\n    expected: json!({\n      \"0\": \"0\",\n      \"1\": \"1\",\n      \"2\": \"2\",\n      \"3\": \"3\",\n      \"4\": \"4\",\n      \"5\": \"5\",\n    })\n  );\n\n  //  Collab restored from snapshot data might equal to full_collab or be a subset of full_collab.\n  let snapshot = Snapshot::decode_v1(snapshot_info.snapshot_meta.snapshot.as_slice()).unwrap();\n  let snapshot_collab = get_snapshot_collab(&full_collab, &snapshot, &object_id);\n  let snapshot_json = snapshot_collab.to_json_value();\n  assert_json_include!(\n    actual: snapshot_json,\n    expected: json!({\n      \"0\": \"0\",\n      \"1\": \"1\",\n      \"2\": \"2\",\n      \"3\": \"3\",\n      \"4\": \"4\",\n      \"5\": \"5\",\n    })\n  );\n}\n\npub fn get_snapshot_collab(collab: &Collab, snapshot: &Snapshot, object_id: &str) -> Collab {\n  let txn = collab.transact();\n  let mut encoder = EncoderV2::new();\n  collab\n    .transact()\n    .encode_state_from_snapshot(snapshot, &mut encoder)\n    .unwrap();\n  let update = Update::decode_v2(&encoder.to_vec()).unwrap();\n  drop(txn);\n\n  let mut collab = Collab::new_with_origin(CollabOrigin::Empty, object_id, vec![], false);\n  let mut txn = collab.transact_mut();\n  txn.apply_update(update).unwrap();\n  drop(txn);\n  collab\n}\n"
  },
  {
    "path": "tests/collab_history/mod.rs",
    "content": "// mod document_history;\n"
  },
  {
    "path": "tests/file_test/delete_dir_test.rs",
    "content": "use crate::collab::util::generate_random_string;\nuse app_error::ErrorCode;\nuse bytes::Bytes;\nuse client_api_test::{generate_unique_registered_user_client, workspace_id_from_client};\nuse database_entity::file_dto::{CompleteUploadRequest, CompletedPartRequest, CreateUploadRequest};\nuse infra::file_util::ChunkedBytes;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn delete_workspace_resource_test() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let data = \"hello world\";\n  let file_id = uuid::Uuid::new_v4().to_string();\n  let url = c1.get_blob_url(&workspace_id, &file_id);\n  c1.put_blob(&url, data, &mime).await.unwrap();\n  c1.delete_workspace(&workspace_id).await.unwrap();\n\n  let error = c1.get_blob(&url).await.unwrap_err();\n  assert_eq!(error.code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn delete_workspace_sub_folder_resource_test() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let parent_dir = format!(\"SubFolder:{}\", uuid::Uuid::new_v4());\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let mut file_ids = vec![];\n\n  for i in 1..5 {\n    let text = generate_random_string(i * 2 * 1024 * 1024);\n    let file_id = Uuid::new_v4().to_string();\n    file_ids.push(file_id.clone());\n    let upload = c1\n      .create_upload(\n        &workspace_id,\n        CreateUploadRequest {\n          file_id: file_id.clone(),\n          parent_dir: parent_dir.clone(),\n          content_type: mime.to_string(),\n          file_size: Some(text.len() as u64),\n        },\n      )\n      .await\n      .unwrap();\n\n    let chunked_bytes = ChunkedBytes::from_bytes(Bytes::from(text.clone())).unwrap();\n    let mut completed_parts = Vec::new();\n    let iter = chunked_bytes.iter().enumerate();\n    for (index, next) in iter {\n      let resp = c1\n        .upload_part(\n          &workspace_id,\n          &parent_dir,\n          &file_id,\n          &upload.upload_id,\n          index as i32 + 1,\n          next.to_vec(),\n        )\n        .await\n        .unwrap();\n\n      completed_parts.push(CompletedPartRequest {\n        e_tag: resp.e_tag,\n        part_number: resp.part_num,\n      });\n    }\n\n    let req = CompleteUploadRequest {\n      file_id: file_id.clone(),\n      parent_dir: parent_dir.clone(),\n      upload_id: upload.upload_id,\n      parts: completed_parts,\n    };\n    c1.complete_upload(&workspace_id, req).await.unwrap();\n\n    let blob = c1\n      .get_blob_v1(&workspace_id, &parent_dir, &file_id)\n      .await\n      .unwrap()\n      .1;\n    let blob_text = String::from_utf8(blob.to_vec()).unwrap();\n\n    let url = c1.get_blob_url_v1(&workspace_id, &parent_dir, &file_id);\n    let (workspace_id_2, parent_dir_2, file_id_2) = c1.parse_blob_url_v1(&url).unwrap();\n    assert_eq!(workspace_id, workspace_id_2);\n    assert_eq!(parent_dir, parent_dir_2);\n    assert_eq!(file_id, file_id_2);\n    assert_eq!(blob_text, text);\n  }\n  c1.delete_workspace(&workspace_id).await.unwrap();\n\n  for file_id in file_ids {\n    let error = c1\n      .get_blob_v1(&workspace_id, &parent_dir, &file_id)\n      .await\n      .unwrap_err();\n    assert_eq!(error.code, ErrorCode::RecordNotFound);\n  }\n}\n"
  },
  {
    "path": "tests/file_test/mod.rs",
    "content": "use std::borrow::Cow;\nuse std::ops::Deref;\n\nmod delete_dir_test;\nmod multiple_part_test;\nmod put_and_get;\nmod usage;\n\nuse appflowy_cloud::application::get_aws_s3_client;\nuse appflowy_cloud::config::config::S3Setting;\nuse database::file::s3_client_impl::AwsS3BucketClientImpl;\nuse lazy_static::lazy_static;\nuse secrecy::Secret;\nuse tracing::warn;\n\nlazy_static! {\n  pub static ref LOCALHOST_MINIO_URL: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_MINIO_URL\", \"http://localhost:9000\");\n  pub static ref LOCALHOST_MINIO_ACCESS_KEY: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_MINIO_ACCESS_KEY\", \"minioadmin\");\n  pub static ref LOCALHOST_MINIO_SECRET_KEY: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_MINIO_SECRET_KEY\", \"minioadmin\");\n  pub static ref LOCALHOST_MINIO_BUCKET_NAME: Cow<'static, str> =\n    get_env_var(\"LOCALHOST_MINIO_BUCKET_NAME\", \"appflowy\");\n}\n\npub struct TestBucket(pub AwsS3BucketClientImpl);\n\nimpl Deref for TestBucket {\n  type Target = AwsS3BucketClientImpl;\n\n  fn deref(&self) -> &Self::Target {\n    &self.0\n  }\n}\n\nimpl TestBucket {\n  pub async fn new() -> Self {\n    let setting = S3Setting {\n      create_bucket: true,\n      use_minio: true,\n      minio_url: LOCALHOST_MINIO_URL.to_string(),\n      access_key: LOCALHOST_MINIO_ACCESS_KEY.to_string(),\n      secret_key: Secret::new(LOCALHOST_MINIO_SECRET_KEY.to_string()),\n      bucket: LOCALHOST_MINIO_BUCKET_NAME.to_string(),\n      region: \"\".to_string(),\n      presigned_url_endpoint: None,\n    };\n    let client = AwsS3BucketClientImpl::new(\n      get_aws_s3_client(&setting).await.unwrap(),\n      setting.bucket.clone(),\n      LOCALHOST_MINIO_URL.to_string(),\n      setting.presigned_url_endpoint.clone(),\n    );\n    Self(client)\n  }\n}\n\nfn get_env_var<'default>(key: &str, default: &'default str) -> Cow<'default, str> {\n  dotenvy::dotenv().ok();\n  match std::env::var(key) {\n    Ok(value) => Cow::Owned(value),\n    Err(_) => {\n      warn!(\"could not read env var {}: using default: {}\", key, default);\n      Cow::Borrowed(default)\n    },\n  }\n}\n"
  },
  {
    "path": "tests/file_test/multiple_part_test.rs",
    "content": "use super::TestBucket;\nuse crate::collab::util::{generate_random_bytes, generate_random_string};\nuse app_error::ErrorCode;\nuse appflowy_cloud::api::file_storage::BlobPathV1;\nuse aws_sdk_s3::types::CompletedPart;\nuse bytes::Bytes;\nuse client_api_test::{generate_unique_registered_user_client, workspace_id_from_client};\nuse database::file::{BlobKey, BucketClient, ResponseBlob};\nuse database_entity::file_dto::{\n  CompleteUploadRequest, CompletedPartRequest, CreateUploadRequest, UploadPartData,\n};\nuse infra::file_util::ChunkedBytes;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn multiple_part_put_and_get_test() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let parent_dir = workspace_id.to_string();\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let text = generate_random_string(8 * 1024 * 1024);\n  let file_id = Uuid::new_v4().to_string();\n\n  let upload = c1\n    .create_upload(\n      &workspace_id,\n      CreateUploadRequest {\n        file_id: file_id.clone(),\n        parent_dir: parent_dir.to_string(),\n        content_type: mime.to_string(),\n        file_size: Some(text.len() as u64),\n      },\n    )\n    .await\n    .unwrap();\n  let mut chunked_bytes = ChunkedBytes::from_bytes(Bytes::from(text.clone())).unwrap();\n  assert_eq!(chunked_bytes.offsets.len(), 2);\n  chunked_bytes.set_chunk_size(5 * 1024 * 1024).unwrap();\n\n  let mut completed_parts = Vec::new();\n  let iter = chunked_bytes.iter().enumerate();\n  for (index, next) in iter {\n    let resp = c1\n      .upload_part(\n        &workspace_id,\n        &parent_dir,\n        &file_id,\n        &upload.upload_id,\n        index as i32 + 1,\n        next.to_vec(),\n      )\n      .await\n      .unwrap();\n\n    completed_parts.push(CompletedPartRequest {\n      e_tag: resp.e_tag,\n      part_number: resp.part_num,\n    });\n  }\n\n  assert_eq!(completed_parts.len(), 2);\n  assert_eq!(completed_parts[0].part_number, 1);\n  assert_eq!(completed_parts[1].part_number, 2);\n\n  let req = CompleteUploadRequest {\n    file_id: file_id.clone(),\n    parent_dir: parent_dir.clone(),\n    upload_id: upload.upload_id,\n    parts: completed_parts,\n  };\n  c1.complete_upload(&workspace_id, req).await.unwrap();\n\n  let blob = c1\n    .get_blob_v1(&workspace_id, &parent_dir, &file_id)\n    .await\n    .unwrap()\n    .1;\n\n  let blob_text = String::from_utf8(blob.to_vec()).unwrap();\n  assert_eq!(blob_text, text);\n}\n\n#[tokio::test]\nasync fn single_part_put_and_get_test() {\n  // Test with smaller file (single part)\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let text = generate_random_string(1024);\n  let file_id = Uuid::new_v4().to_string();\n\n  let upload = c1\n    .create_upload(\n      &workspace_id,\n      CreateUploadRequest {\n        file_id: file_id.clone(),\n        parent_dir: workspace_id.to_string(),\n        content_type: mime.to_string(),\n        file_size: Some(text.len() as u64),\n      },\n    )\n    .await\n    .unwrap();\n\n  let chunked_bytes = ChunkedBytes::from_bytes(Bytes::from(text.clone())).unwrap();\n  assert_eq!(chunked_bytes.offsets.len(), 1);\n\n  let mut completed_parts = Vec::new();\n  let iter = chunked_bytes.iter().enumerate();\n  for (index, next) in iter {\n    let resp = c1\n      .upload_part(\n        &workspace_id,\n        &workspace_id.to_string(),\n        &file_id,\n        &upload.upload_id,\n        index as i32 + 1,\n        next.to_vec(),\n      )\n      .await\n      .unwrap();\n\n    completed_parts.push(CompletedPartRequest {\n      e_tag: resp.e_tag,\n      part_number: resp.part_num,\n    });\n  }\n  assert_eq!(completed_parts.len(), 1);\n  assert_eq!(completed_parts[0].part_number, 1);\n\n  let req = CompleteUploadRequest {\n    file_id: file_id.clone(),\n    parent_dir: workspace_id.to_string(),\n    upload_id: upload.upload_id,\n    parts: completed_parts,\n  };\n  c1.complete_upload(&workspace_id, req).await.unwrap();\n\n  let blob = c1\n    .get_blob_v1(&workspace_id, &workspace_id.to_string(), &file_id)\n    .await\n    .unwrap()\n    .1;\n  let blob_text = String::from_utf8(blob.to_vec()).unwrap();\n  assert_eq!(blob_text, text);\n}\n\n#[tokio::test]\nasync fn empty_part_upload_test() {\n  // Test with empty part\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let file_id = Uuid::new_v4().to_string();\n\n  let upload = c1\n    .create_upload(\n      &workspace_id,\n      CreateUploadRequest {\n        file_id: file_id.clone(),\n        parent_dir: workspace_id.to_string(),\n        content_type: mime.to_string(),\n        file_size: Some(0),\n      },\n    )\n    .await\n    .unwrap();\n\n  let result = c1\n    .upload_part(&workspace_id, \"\", &file_id, &upload.upload_id, 1, vec![])\n    .await\n    .unwrap_err();\n  assert_eq!(result.code, ErrorCode::InvalidRequest)\n}\n\n#[tokio::test]\nasync fn multiple_part_upload_test() {\n  let test_bucket = TestBucket::new().await;\n\n  // Test with a payload of less than 5MB\n  let small_file_size = 4 * 1024 * 1024; // 4 MB\n  let small_blob = generate_random_bytes(small_file_size);\n  perform_upload_test(&test_bucket, small_blob, small_file_size, \"small_file\").await;\n\n  // Test with a payload of exactly 10MB\n  let file_size = 10 * 1024 * 1024; // 10 MB\n  let blob = generate_random_bytes(file_size);\n  perform_upload_test(&test_bucket, blob, file_size, \"large_file\").await;\n\n  // Test with a payload of exactly 20MB\n  let file_size = 20 * 1024 * 1024; // 20 MB\n  let blob = generate_random_bytes(file_size);\n  perform_upload_test(&test_bucket, blob, file_size, \"large_file\").await;\n}\n\n#[tokio::test]\n#[should_panic]\nasync fn multiple_part_upload_empty_data_test() {\n  let test_bucket = TestBucket::new().await;\n  let empty_blob = Vec::new();\n  perform_upload_test(&test_bucket, empty_blob, 0, \"empty_file\").await;\n}\n\nasync fn perform_upload_test(\n  test_bucket: &TestBucket,\n  blob: Vec<u8>,\n  file_size: usize,\n  description: &str,\n) {\n  let chunk_size = 5 * 1024 * 1024; // 5 MB\n  let file_id = Uuid::new_v4().to_string();\n  let workspace_id = Uuid::new_v4();\n  let parent_dir = workspace_id.to_string();\n\n  let req = CreateUploadRequest {\n    file_id: file_id.clone(),\n    parent_dir: parent_dir.clone(),\n    content_type: \"text\".to_string(),\n    file_size: Some(file_size as u64),\n  };\n\n  let key = BlobPathV1 {\n    workspace_id,\n    parent_dir: parent_dir.clone(),\n    file_id,\n  };\n  let upload = test_bucket\n    .create_upload(&key.object_key(), req)\n    .await\n    .unwrap();\n\n  let mut chunk_count = (file_size / chunk_size) + 1;\n  let mut size_of_last_chunk = file_size % chunk_size;\n  if size_of_last_chunk == 0 {\n    size_of_last_chunk = chunk_size;\n    chunk_count -= 1;\n  }\n\n  let mut completed_parts = Vec::new();\n  for chunk_index in 0..chunk_count {\n    let start = chunk_index * chunk_size;\n    let end = start\n      + if chunk_index == chunk_count - 1 {\n        size_of_last_chunk\n      } else {\n        chunk_size\n      };\n\n    let chunk = &blob[start..end];\n    let part_number = (chunk_index + 1) as i32;\n\n    let req = UploadPartData {\n      file_id: upload.file_id.clone(),\n      upload_id: upload.upload_id.clone(),\n      part_number,\n      body: chunk.to_vec(),\n    };\n    let key = BlobPathV1 {\n      workspace_id,\n      parent_dir: parent_dir.clone(),\n      file_id: upload.file_id.clone(),\n    };\n    let resp = test_bucket\n      .upload_part(&key.object_key(), req)\n      .await\n      .unwrap();\n\n    completed_parts.push(\n      CompletedPart::builder()\n        .e_tag(resp.e_tag)\n        .part_number(resp.part_num)\n        .build(),\n    );\n  }\n\n  let complete_req = CompleteUploadRequest {\n    file_id: upload.file_id.clone(),\n    parent_dir: parent_dir.clone(),\n    upload_id: upload.upload_id.clone(),\n    parts: completed_parts\n      .into_iter()\n      .map(|p| CompletedPartRequest {\n        e_tag: p.e_tag().unwrap().to_string(),\n        part_number: p.part_number.unwrap(),\n      })\n      .collect(),\n  };\n\n  let key = BlobPathV1 {\n    workspace_id,\n    parent_dir: parent_dir.clone(),\n    file_id: upload.file_id.clone(),\n  };\n  test_bucket\n    .complete_upload(&key.object_key(), complete_req)\n    .await\n    .unwrap();\n\n  // Verify the upload\n  let object = test_bucket.get_blob(&key.object_key()).await.unwrap();\n  assert_eq!(object.len(), file_size, \"Failed for {}\", description);\n  assert_eq!(object.to_blob(), blob, \"Failed for {}\", description);\n}\n\n#[tokio::test]\nasync fn invalid_test() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let parent_dir = workspace_id;\n  let file_id = uuid::Uuid::new_v4().to_string();\n  let mime = mime::TEXT_PLAIN_UTF_8;\n\n  // test invalid create upload request\n  for request in [\n    CreateUploadRequest {\n      file_id: \"\".to_string(),\n      parent_dir: parent_dir.to_string(),\n      content_type: mime.to_string(),\n      file_size: Some(0),\n    },\n    CreateUploadRequest {\n      file_id: file_id.clone(),\n      parent_dir: \"\".to_string(),\n      content_type: mime.to_string(),\n      file_size: Some(0),\n    },\n  ] {\n    let err = c1.create_upload(&workspace_id, request).await.unwrap_err();\n    assert_eq!(err.code, ErrorCode::InvalidRequest);\n  }\n\n  // test invalid upload part request\n  let upload_id = uuid::Uuid::new_v4().to_string();\n  for request in vec![\n    // workspace_id, parent_dir, file_id, upload_id, part_number, body\n    (\n      workspace_id,\n      \"\".to_string(),\n      file_id.clone(),\n      upload_id.clone(),\n      1,\n      vec![1, 2, 3],\n    ),\n    (\n      workspace_id,\n      parent_dir.to_string(),\n      \"\".to_string(),\n      upload_id.clone(),\n      1,\n      vec![1, 2, 3],\n    ),\n  ] {\n    let err = c1\n      .upload_part(\n        &request.0, &request.1, &request.2, &request.3, request.4, request.5,\n      )\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::Internal);\n  }\n}\n\n#[tokio::test]\nasync fn multiple_level_dir_upload_file_test() {\n  // Test with smaller file (single part)\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let text = generate_random_string(1024);\n  let file_id = Uuid::new_v4().to_string();\n  let parent_dir = \"file/v1/image\".to_string();\n  let upload = c1\n    .create_upload(\n      &workspace_id,\n      CreateUploadRequest {\n        file_id: file_id.clone(),\n        parent_dir: parent_dir.clone(),\n        content_type: mime.to_string(),\n        file_size: Some(text.len() as u64),\n      },\n    )\n    .await\n    .unwrap();\n  let chunked_bytes = ChunkedBytes::from_bytes(Bytes::from(text.clone())).unwrap();\n  let mut completed_parts = Vec::new();\n  let iter = chunked_bytes.iter().enumerate();\n  for (index, next) in iter {\n    let resp = c1\n      .upload_part(\n        &workspace_id,\n        &parent_dir,\n        &file_id,\n        &upload.upload_id,\n        index as i32 + 1,\n        next.to_vec(),\n      )\n      .await\n      .unwrap();\n\n    completed_parts.push(CompletedPartRequest {\n      e_tag: resp.e_tag,\n      part_number: resp.part_num,\n    });\n  }\n  let req = CompleteUploadRequest {\n    file_id: file_id.clone(),\n    parent_dir: parent_dir.clone(),\n    upload_id: upload.upload_id,\n    parts: completed_parts,\n  };\n  c1.complete_upload(&workspace_id, req).await.unwrap();\n\n  let blob = c1\n    .get_blob_v1(&workspace_id, &parent_dir, &file_id)\n    .await\n    .unwrap()\n    .1;\n\n  let blob_text = String::from_utf8(blob.to_vec()).unwrap();\n  assert_eq!(blob_text, text);\n}\n"
  },
  {
    "path": "tests/file_test/put_and_get.rs",
    "content": "use super::TestBucket;\nuse uuid::Uuid;\n\nuse app_error::ErrorCode;\n\nuse client_api_test::{generate_unique_registered_user_client, workspace_id_from_client};\nuse database::file::{BucketClient, ResponseBlob};\n\n#[tokio::test]\nasync fn get_but_not_exists() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let url = c1.get_blob_url(&Uuid::new_v4(), \"world\");\n  let err = c1.get_blob(&url).await.unwrap_err();\n  assert_eq!(err.code, ErrorCode::RecordNotFound);\n\n  let workspace_id = c1\n    .get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id;\n\n  let url = c1.get_blob_url(&workspace_id, \"world\");\n  let err = c1.get_blob(&url).await.unwrap_err();\n  assert_eq!(err.code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn put_and_get() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let data = \"hello world\";\n  let file_id = Uuid::new_v4().to_string();\n  let url = c1.get_blob_url(&workspace_id, &file_id);\n  c1.put_blob(&url, data, &mime).await.unwrap();\n\n  let (got_mime, got_data) = c1.get_blob(&url).await.unwrap();\n  assert_eq!(got_data, Vec::from(data));\n  assert_eq!(got_mime, mime);\n\n  c1.delete_blob(&url).await.unwrap();\n}\n\n#[tokio::test]\nasync fn put_and_put_and_get() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let data1 = \"my content 1\";\n  let data2 = \"my content 2\";\n  let file_id_1 = Uuid::new_v4().to_string();\n  let file_id_2 = Uuid::new_v4().to_string();\n  let url_1 = c1.get_blob_url(&workspace_id, &file_id_1);\n  let url_2 = c1.get_blob_url(&workspace_id, &file_id_2);\n  c1.put_blob(&url_1, data1, &mime).await.unwrap();\n  c1.put_blob(&url_2, data2, &mime).await.unwrap();\n\n  let (got_mime, got_data) = c1.get_blob(&url_1).await.unwrap();\n  assert_eq!(got_data, Vec::from(data1));\n  assert_eq!(got_mime, mime);\n\n  let (got_mime, got_data) = c1.get_blob(&url_2).await.unwrap();\n  assert_eq!(got_data, Vec::from(data2));\n  assert_eq!(got_mime, mime);\n\n  c1.delete_blob(&url_1).await.unwrap();\n  c1.delete_blob(&url_2).await.unwrap();\n}\n\n#[tokio::test]\nasync fn put_delete_get() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let data = \"my contents\";\n  let file_id = Uuid::new_v4().to_string();\n  let url = c1.get_blob_url(&workspace_id, &file_id);\n  c1.put_blob(&url, data, &mime).await.unwrap();\n  c1.delete_blob(&url).await.unwrap();\n\n  let url = c1.get_blob_url(&workspace_id, &file_id);\n  let err = c1.get_blob(&url).await.unwrap_err();\n  assert_eq!(err.code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn put_and_delete_workspace() {\n  let test_bucket = TestBucket::new().await;\n\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n  let file_id = Uuid::new_v4().to_string();\n  let blob_to_put = \"some contents 1\";\n  {\n    // put blob\n    let mime = mime::TEXT_PLAIN_UTF_8;\n    let url = c1.get_blob_url(&workspace_id, &file_id);\n    c1.put_blob(&url, blob_to_put, &mime).await.unwrap();\n  }\n\n  {\n    // blob exists in the bucket\n    let obj_key = format!(\"{}/{}\", workspace_id, file_id);\n    let raw_data = test_bucket.get_blob(&obj_key).await.unwrap().to_blob();\n    assert_eq!(blob_to_put, String::from_utf8_lossy(&raw_data));\n  }\n\n  // delete workspace\n  c1.delete_workspace(&workspace_id).await.unwrap();\n\n  {\n    // blob does not exist in the bucket\n    let obj_key = format!(\"{}/{}\", workspace_id, file_id);\n    let err = test_bucket.get_blob(&obj_key).await.unwrap_err();\n    assert!(err.is_record_not_found());\n  }\n}\n\n#[tokio::test]\nasync fn simulate_30_put_blob_request_test() {\n  let (c1, _user1) = generate_unique_registered_user_client().await;\n  let workspace_id = workspace_id_from_client(&c1).await;\n\n  let mut handles = vec![];\n  for _ in 0..30 {\n    let cloned_client = c1.clone();\n    let cloned_workspace_id = workspace_id;\n    let handle = tokio::spawn(async move {\n      let mime = mime::TEXT_PLAIN_UTF_8;\n      let file_id = Uuid::new_v4().to_string();\n      let url = cloned_client.get_blob_url(&cloned_workspace_id, &file_id);\n      let data = vec![0; 3 * 1024 * 1024];\n      cloned_client.put_blob(&url, data, &mime).await.unwrap();\n      url\n    });\n    handles.push(handle);\n  }\n\n  let results = futures::future::join_all(handles).await;\n  for result in results {\n    let url = result.unwrap();\n    let (_, got_data) = c1.get_blob(&url).await.unwrap();\n    assert_eq!(got_data, vec![0; 3 * 1024 * 1024]);\n    c1.delete_blob(&url).await.unwrap();\n  }\n}\n"
  },
  {
    "path": "tests/file_test/usage.rs",
    "content": "use client_api_test::TestClient;\n\n#[tokio::test]\nasync fn workspace_usage_put_blob_test() {\n  let client = TestClient::new_user_without_ws_conn().await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let file_id_1 = uuid::Uuid::new_v4().to_string();\n  let file_id_2 = uuid::Uuid::new_v4().to_string();\n  client.upload_blob(&file_id_1, \"123\", &mime).await;\n  client.upload_blob(&file_id_2, \"456\", &mime).await;\n\n  let usage = client.get_workspace_usage().await;\n  assert_eq!(usage.consumed_capacity, 6);\n\n  // after the test, delete the files\n  client.delete_file(&file_id_1).await;\n  client.delete_file(&file_id_2).await;\n}\n\n#[tokio::test]\nasync fn workspace_usage_put_and_then_delete_blob_test() {\n  let client = TestClient::new_user_without_ws_conn().await;\n  let mime = mime::TEXT_PLAIN_UTF_8;\n  let file_id_1 = uuid::Uuid::new_v4().to_string();\n  let file_id_2 = uuid::Uuid::new_v4().to_string();\n  client.upload_blob(&file_id_1, \"123\", &mime).await;\n  client.upload_blob(&file_id_2, \"456\", &mime).await;\n\n  client.delete_file(&file_id_1).await;\n  let usage = client.get_workspace_usage().await;\n  assert_eq!(usage.consumed_capacity, 3);\n\n  client.delete_file(&file_id_2).await;\n  let usage = client.get_workspace_usage().await;\n  assert_eq!(usage.consumed_capacity, 0);\n}\n"
  },
  {
    "path": "tests/gotrue/admin.rs",
    "content": "use client_api_test::*;\nuse gotrue::{\n  api::Client,\n  grant::{Grant, PasswordGrant},\n  params::{AdminDeleteUserParams, AdminUserParams, GenerateLinkParams},\n};\n\n#[tokio::test]\nasync fn admin_user_create_list_edit_delete() {\n  let http_client = reqwest::Client::new();\n  let gotrue_client = Client::new(http_client, &LOCALHOST_GOTRUE);\n  let admin_token = gotrue_client\n    .token(&Grant::Password(PasswordGrant {\n      email: ADMIN_USER.email.clone(),\n      password: ADMIN_USER.password.clone(),\n    }))\n    .await\n    .unwrap();\n\n  // new user params\n  let user_email = generate_unique_email();\n  let user_password = \"Hello123!\";\n\n  // create user\n  let admin_user_params: AdminUserParams = AdminUserParams {\n    email: user_email.clone(),\n    password: Some(user_password.to_string()),\n    email_confirm: true,\n    ..Default::default()\n  };\n  let user = gotrue_client\n    .admin_add_user(&admin_token.access_token, &admin_user_params)\n    .await\n    .unwrap();\n  assert_eq!(user.email, user_email);\n  assert!(user.email_confirmed_at.is_some());\n\n  // login user\n  let user_token = gotrue_client\n    .token(&Grant::Password(PasswordGrant {\n      email: user_email.clone(),\n      password: user_password.to_string(),\n    }))\n    .await\n    .unwrap();\n  assert!(user_token.user.email_confirmed_at.is_some());\n\n  // list users\n  let users = gotrue_client\n    .admin_list_user(&admin_token.access_token, None)\n    .await\n    .unwrap()\n    .users;\n\n  // should be able to find user that was just created\n  let new_user = users.iter().find(|u| u.email == user_email).unwrap();\n\n  // change password for user\n  let new_password = \"Hello456!\";\n  let _ = gotrue_client\n    .admin_update_user(\n      &admin_token.access_token,\n      new_user.id.as_str(),\n      &AdminUserParams {\n        email: user_email.clone(),\n        password: Some(new_password.to_owned()),\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap();\n  assert_eq!(user.email, user_email);\n  assert!(user.email_confirmed_at.is_some());\n\n  // login user with new password\n  let _ = gotrue_client\n    .token(&Grant::Password(PasswordGrant {\n      email: user_email.clone(),\n      password: new_password.to_string(),\n    }))\n    .await\n    .unwrap();\n\n  // delete user that was just created\n  gotrue_client\n    .admin_delete_user(\n      &admin_token.access_token,\n      &new_user.id,\n      &AdminDeleteUserParams {\n        should_soft_delete: true,\n      },\n    )\n    .await\n    .unwrap();\n\n  let users = gotrue_client\n    .admin_list_user(&admin_token.access_token, None)\n    .await\n    .unwrap()\n    .users;\n\n  // user list should not contain the new user added\n  // since it's deleted\n  let found = users.iter().any(|u| u.email == user_email);\n  assert!(!found);\n}\n\n#[tokio::test]\nasync fn admin_generate_link_and_user_sign_in_and_invite() {\n  // admin generate link for new user\n  let new_user_sign_in_link = {\n    let http_client = reqwest::Client::new();\n    let gotrue_client = Client::new(http_client, &LOCALHOST_GOTRUE);\n    let admin_token = gotrue_client\n      .token(&Grant::Password(PasswordGrant {\n        email: ADMIN_USER.email.clone(),\n        password: ADMIN_USER.password.clone(),\n      }))\n      .await\n      .unwrap();\n\n    // new user params\n    let user_email = generate_unique_email();\n\n    // create link\n    let admin_user_params: GenerateLinkParams = GenerateLinkParams {\n      email: user_email.clone(),\n      ..Default::default()\n    };\n    let link_resp = gotrue_client\n      .admin_generate_link(&admin_token.access_token, &admin_user_params)\n      .await\n      .unwrap();\n\n    assert_eq!(link_resp.email, user_email);\n    link_resp.action_link\n  };\n\n  // new user sign in with link,\n  // invite another user through magic link\n  {\n    let client = localhost_client();\n    let appflowy_sign_in_url = client\n      .extract_sign_in_url(&new_user_sign_in_link)\n      .await\n      .unwrap();\n\n    let is_new = client\n      .sign_in_with_url(&appflowy_sign_in_url)\n      .await\n      .unwrap();\n    assert!(is_new);\n\n    let workspaces = client.get_workspaces().await.unwrap();\n    assert_eq!(workspaces.len(), 1);\n\n    let friend_email = generate_unique_email();\n    client.invite(&friend_email).await.unwrap();\n  }\n}\n"
  },
  {
    "path": "tests/gotrue/health.rs",
    "content": "use client_api_test::LOCALHOST_GOTRUE;\nuse gotrue::api::Client;\n\n#[tokio::test]\nasync fn gotrue_health() {\n  let http_client = reqwest::Client::new();\n  let gotrue_client = Client::new(http_client, &LOCALHOST_GOTRUE);\n  gotrue_client.health().await.unwrap();\n}\n"
  },
  {
    "path": "tests/gotrue/mod.rs",
    "content": "mod admin;\nmod health;\nmod settings;\n"
  },
  {
    "path": "tests/gotrue/settings.rs",
    "content": "use client_api_test::{generate_unique_email, ADMIN_USER, LOCALHOST_GOTRUE};\nuse gotrue::{\n  api::Client,\n  grant::{Grant, PasswordGrant},\n  params::AdminUserParams,\n};\n\n#[tokio::test]\nasync fn gotrue_settings() {\n  let http_client = reqwest::Client::new();\n  let gotrue_client = Client::new(http_client, &LOCALHOST_GOTRUE);\n  gotrue_client.settings().await.unwrap();\n}\n\n#[tokio::test]\nasync fn admin_user_create() {\n  let http_client = reqwest::Client::new();\n  let gotrue_client = Client::new(http_client, &LOCALHOST_GOTRUE);\n  let admin_token = gotrue_client\n    .token(&Grant::Password(PasswordGrant {\n      email: ADMIN_USER.email.clone(),\n      password: ADMIN_USER.password.clone(),\n    }))\n    .await\n    .unwrap();\n\n  // new user params\n  let user_email = generate_unique_email();\n  let user_password = \"Hello123!\";\n\n  // create user\n  let admin_user_params: AdminUserParams = AdminUserParams {\n    email: user_email.clone(),\n    password: Some(user_password.to_string()),\n    email_confirm: true,\n    ..Default::default()\n  };\n  let user = gotrue_client\n    .admin_add_user(&admin_token.access_token, &admin_user_params)\n    .await\n    .unwrap();\n  assert_eq!(user.email, user_email);\n  assert!(user.email_confirmed_at.is_some());\n\n  // login user\n  let user_token = gotrue_client\n    .token(&Grant::Password(PasswordGrant {\n      email: user_email.clone(),\n      password: user_password.to_string(),\n    }))\n    .await\n    .unwrap();\n  assert!(user_token.user.email_confirmed_at.is_some());\n}\n"
  },
  {
    "path": "tests/main.rs",
    "content": "mod ai_test;\nmod collab;\nmod collab_history;\nmod file_test;\nmod gotrue;\nmod search;\nmod server_info;\nmod sql_test;\nmod user;\nmod websocket;\nmod workspace;\nmod yrs_version;\n"
  },
  {
    "path": "tests/search/asset/appflowy_values.md",
    "content": "# AppFlowy Values\n\n## Mission Driven\n\n- Our mission is to enable everyone to unleash the potential and achieve more with secure workplace tools.\n- We are true believers in open source—a fundamentally superior approach to achieve the mission.\n- We actively lead and support the AppFlowy open-source community, where a diverse group of people is empowered to\n  contribute to the common good.\n- We think strategically, make wise decisions, and act accordingly, with an eye toward what’s sustainable in the long\n  run and not what’s convenient in the moment.\n\n## Aim High and Iterate\n\n1. We strive for excellence with a growth mindset.\n2. We dream big, start small, and move fast.\n3. We take smaller steps and ship smaller, simpler features.\n4. We don’t wait, but instead iterate and work as part of the community.\n5. We focus on results over process and prioritize progress over perfection.\n\n## Transparency\n\n1. We make information about AppFlowy public by default unless there is a compelling reason not to.\n2. We are straightforward and kind with ourselves and each other.\n\n- We surface issues constructively and proactively.\n- We say “why” and provide sufficient context for our actions rather than just disclosing the “what.”\n\n## Collaboration\n\n> We pride ourselves on being a great team.\n>\n\n> We foster collaboration, value diversity and inclusion, and encourage sharing.\n>\n\n> We thrive as individuals within the context of our team and succeed together.\n>\n\n> We play very effectively with people of diverse backgrounds and cultures.\n>\n\n> We make time to help each other in pursuit of our common goals.\n>\n\nHonesty\n\nWe are honest with ourselves.\n\nWe admit mistakes freely and openly.\n\nWe provide candid, helpful, timely feedback to colleagues with respect, regardless of their status or whether they\ndisagree with us.\n\nWe are vulnerable in search of truth and don’t defend our point to just win over others."
  },
  {
    "path": "tests/search/asset/kathryn_tennis_story.md",
    "content": "Kathryn’s Journey to Becoming a Tennis Player\n\nKathryn’s love for tennis began on a warm summer day when she was eight years old. She stumbled across a local park\nwhere players were volleying back and forth. The sound of the ball hitting the racket and the sheer energy of the game\ncaptivated her. That evening, she begged her parents for a tennis racket, and the very next weekend, she was on the\ncourt for the first time.\n\nLearning the Basics\n\nKathryn’s first lessons were clumsy but full of enthusiasm. She struggled with her serves, missed easy shots, and often\nhit the ball over the fence. But every mistake made her more determined to improve. Her first coach, Mr. Evans, taught\nher the fundamentals—how to grip the racket, the importance of footwork, and how to keep her eye on the ball. “Tennis is\nabout focus and persistence,” he would say, and Kathryn took that advice to heart.\n\nBy the time she was 12, Kathryn was playing in local junior tournaments. At first, she lost more matches than she won,\nbut she never let the defeats discourage her. “Every loss teaches you something,” she told herself. Gradually, her\nskills improved, and she started to win.\n\nThe Turning Point\n\nAs Kathryn entered high school, her passion for tennis only grew stronger. She spent hours after school practicing her\nbackhand and perfecting her serve. She joined her school’s tennis team, where she met her new coach, Ms. Carter. Unlike\nher earlier coaches, Ms. Carter focused on strategy and mental toughness.\n\n“Kathryn, tennis isn’t just physical. It’s a mental game too,” she said one day after a tough match. “You need to stay\ncalm under pressure and think a few steps ahead of your opponent.”\n\nThat advice changed everything for Kathryn. She began analyzing her matches, understanding her opponents’ patterns, and\nusing strategy to outplay them. By her senior year, she was the captain of her team and had won several regional\nchampionships.\n\nChasing the Dream\n\nAfter high school, Kathryn decided to pursue tennis seriously. She joined a competitive training academy, where the\npractices were grueling, and the competition was fierce. There were times she doubted herself, especially after losing\nmatches to stronger players. But her love for the game kept her going.\n\nHer coaches helped her refine her technique, adding finesse to her volleys and power to her forehand. She also learned\nto play smarter, conserving energy during long matches and capitalizing on her opponents’ weaknesses.\n\nBecoming a Player\n\nBy the time Kathryn was in her early 20s, she was competing in national tournaments. She wasn’t the biggest name on the\ncourt, but her hard work and persistence earned her respect. Each match was a chance to learn, grow, and prove herself.\n\nShe eventually won her first title at a mid-level tournament, a moment she would never forget. Standing on the podium,\nholding the trophy, she realized how far she had come—from the little girl who couldn’t hit a serve to a tennis player\nwith real potential.\n\nA Life of Tennis\n\nToday, Kathryn continues to play with the same passion she had when she first picked up a racket. She travels to\ntournaments, trains every day, and inspires young players to follow their dreams. For her, tennis is more than a\nsport—it’s a lifelong journey of growth, persistence, and joy."
  },
  {
    "path": "tests/search/asset/the_five_dysfunctions_of_a_team.md",
    "content": "# *The Five Dysfunctions of a Team* by Patrick Lencioni\n\n*The Five Dysfunctions of a Team* by Patrick Lencioni is a compelling exploration of team dynamics and the common\npitfalls that undermine successful collaboration. Through the lens of a fictional story about a Silicon Valley startup,\nDecisionTech, and its CEO Kathryn Petersen, Lencioni provides a practical framework to address and resolve issues that\ncommonly disrupt team cohesion and performance. Below is a chapter-by-chapter look at the book’s content, capturing its\nessential lessons and actionable insights.\n\n---\n\n## Part I: Underachievement\n\nIn this introductory section, we meet Kathryn Petersen, the newly appointed CEO of DecisionTech, a struggling Silicon\nValley startup with a dysfunctional executive team. Kathryn steps into a role where the team is plagued by poor\ncommunication, lack of trust, and weak commitment.\n\nLencioni uses this setup to introduce readers to the core problems affecting team productivity and morale. Kathryn\nrealizes that the team’s challenges are deeply rooted in its dynamics rather than surface-level operational issues.\nThrough her initial observations, she identifies that turning around the team will require addressing foundational\nissues like trust, respect, and open communication.\n\n---\n\n## Part II: Lighting the Fire\n\nTo start addressing these issues, Kathryn organizes an offsite meeting in Napa Valley. This setting becomes a\ntransformative space where Kathryn pushes the team to be present, vulnerable, and engaged. Her goal is to build trust, a\ncritical foundation for any team.\n\nKathryn leads exercises that reveal personal histories, enabling the team members to see each other beyond their\nprofessional roles. She also introduces the idea of constructive conflict, encouraging open discussion about\ndisagreements and differing opinions. Despite the discomfort this causes for some team members who are used to\nindividualistic work styles, Kathryn emphasizes that trust and openness are crucial for effective teamwork.\n\n---\n\n## Part III: Heavy Lifting\n\nWith initial trust in place, Kathryn shifts her focus to accountability and responsibility. This part highlights the\nchallenges team members face when taking ownership of collective goals.\n\nKathryn holds the team to high standards, stressing the importance of addressing issues directly instead of avoiding\nthem. This section also examines the role of healthy conflict as a mechanism for growth, as team members begin to hold\neach other accountable for their contributions. Through challenging conversations, they tackle topics like performance\nexpectations and role clarity. Kathryn’s persistence helps the team understand that embracing accountability is\nessential for progress, even if it leads to uncomfortable discussions.\n\n---\n\n## Part IV: Traction\n\nBy this stage, Kathryn reinforces the team’s commitment to shared goals. The team starts experiencing the tangible\nbenefits of improved trust and open conflict. Accountability has now become an expected part of their routine, and\nmeetings are increasingly productive.\n\nAs they move towards achieving measurable results, the focus shifts from individual successes to collective\nachievements. Kathryn ensures that each member appreciates the value of prioritizing team success over personal gain.\nThrough this unified approach, the team’s motivation and performance visibly improve, demonstrating the power of\ncohesive collaboration.\n\n---\n\n## The Model: Overcoming the Five Dysfunctions\n\nLencioni introduces a model that identifies the five key dysfunctions of a team and provides strategies to overcome\nthem:\n\n1. **Absence of Trust**  \n   The lack of trust prevents team members from being vulnerable and open with each other. Lencioni suggests exercises\n   that encourage personal sharing to build this essential foundation.\n\n2. **Fear of Conflict**  \n   Teams that avoid conflict miss out on critical discussions that lead to better decision-making. Lencioni recommends\n   fostering a safe environment where team members feel comfortable challenging each other’s ideas without fear of\n   reprisal.\n\n3. **Lack of Commitment**  \n   Without clarity and buy-in, team decisions become fragmented. Leaders should ensure everyone understands and agrees\n   on goals to achieve genuine commitment.\n\n4. **Avoidance of Accountability**  \n   When team members don’t hold each other accountable, performance suffers. Regular check-ins and peer accountability\n   encourage responsibility and consistency.\n\n5. **Inattention to Results**  \n   Prioritizing individual goals over collective outcomes dilutes team success. Aligning rewards and recognition with\n   team achievements helps refocus efforts on shared objectives.\n\n---\n\n## Understanding and Overcoming Each Dysfunction\n\nEach dysfunction is further broken down with practical strategies:\n\n- **Building Trust**  \n  Kathryn’s personal history exercise is one example of building trust. By sharing backgrounds and opening up, team\n  members foster a culture of vulnerability and connection.\n\n- **Encouraging Conflict**  \n  Constructive conflict allows ideas to be challenged and strengthened. Kathryn’s insistence on open debate helps the\n  team reach better, more robust decisions.\n\n- **Ensuring Commitment**  \n  Lencioni highlights the importance of clarity and alignment, which Kathryn reinforces by facilitating discussions that\n  ensure all team members are on the same page about their goals.\n\n- **Embracing Accountability**  \n  Accountability becomes ingrained as team members regularly check in with each other, creating a culture of mutual\n  responsibility and high standards.\n\n- **Focusing on Results**  \n  Kathryn’s focus on collective achievements over individual successes aligns with Lencioni’s advice to reward team\n  efforts, ensuring the entire group works toward a shared purpose.\n\n---\n\n## Final Thoughts\n\n*The Five Dysfunctions of a Team* illustrates the importance of cohesive team behavior and effective leadership in\novercoming common organizational challenges. Through Kathryn’s story, Lencioni provides a practical roadmap for leaders\nand teams to diagnose and address dysfunctions, ultimately fostering an environment where trust, accountability, and\nshared goals drive performance.\n\nThis book remains a valuable resource for anyone seeking to understand and improve team dynamics, with lessons that\napply well beyond the workplace."
  },
  {
    "path": "tests/search/document_search.rs",
    "content": "use std::path::PathBuf;\nuse std::time::Duration;\n\nuse appflowy_ai_client::dto::CalculateSimilarityParams;\nuse client_api_test::{ai_test_enabled, collect_answer, TestClient};\nuse collab::core::collab::{default_client_id, CollabOptions};\nuse collab::core::origin::{CollabClient, CollabOrigin};\nuse collab::preclude::Collab;\nuse collab_document::document::Document;\nuse collab_document::importer::md_importer::MDImporter;\nuse collab_entity::CollabType;\nuse collab_folder::ViewLayout;\nuse shared_entity::dto::chat_dto::{CreateChatMessageParams, CreateChatParams};\nuse shared_entity::dto::search_dto::SearchResult;\nuse tokio::time::sleep;\nuse uuid::Uuid;\nuse workspace_template::document::getting_started::getting_started_document_data;\n\n#[tokio::test]\n#[ignore]\nasync fn test_embedding_when_create_document() {\n  if !ai_test_enabled() {\n    return;\n  }\n\n  let mut test_client = TestClient::new_user().await;\n  let uid = test_client.uid().await;\n  let workspace_id = test_client.workspace_id().await;\n  // Create the first document and wait for its embedding.\n  let object_id_1 = add_document_collab(\n    &mut test_client,\n    &workspace_id,\n    \"the_five_dysfunctions_of_a_team.md\",\n    \"five dysfunctional\",\n    true,\n    uid,\n  )\n  .await;\n\n  // Test Search\n  let query = \"Overcoming the Five Dysfunctions\";\n  let items = test_client\n    .wait_unit_get_search_result(&workspace_id, query, 5, 100, Some(0.2))\n    .await\n    .unwrap();\n  dbg!(\"search result: {:?}\", &items);\n\n  // Test search summary\n  let result = test_client\n    .api_client\n    .generate_search_summary(\n      &workspace_id,\n      query,\n      items.iter().map(SearchResult::from).collect(),\n    )\n    .await\n    .unwrap();\n  dbg!(\"search summary: {}\", &result);\n  assert!(!result.summaries.is_empty());\n\n  let previews = items\n    .iter()\n    .map(|item| item.preview.clone().unwrap())\n    .collect::<Vec<String>>()\n    .join(\"\\n\");\n  let expected = \"The Five Dysfunctions of a Team illustrates strategies for overcoming common organizational challenges by fostering trust, accountability, and shared goals to improve team dynamics.\";\n  calculate_similarity_and_assert(\n    &mut test_client,\n    workspace_id,\n    previews,\n    expected,\n    0.7,\n    \"preview score\",\n  )\n  .await;\n\n  // Test irrelevant search\n  let query = \"Hello world\";\n  let items = test_client\n    .api_client\n    .search_documents(&workspace_id, query, 5, 100, Some(0.4))\n    .await\n    .unwrap();\n  assert!(items.is_empty());\n\n  let result = test_client\n    .api_client\n    .generate_search_summary(\n      &workspace_id,\n      query,\n      items.into_iter().map(|v| SearchResult::from(&v)).collect(),\n    )\n    .await\n    .unwrap();\n  assert!(result.summaries.is_empty());\n\n  // Simulate when user click search result to open the document and then chat with it.\n  let answer = create_chat_and_ask_question(\n    &mut test_client,\n    &workspace_id,\n    object_id_1,\n    \"chat with the five dysfunctions of a team\",\n    \"Kathryn CEO of DecisionTech\",\n  )\n  .await;\n\n  let expected_answer = r#\"\nKathryn Petersen is the newly appointed CEO of DecisionTech, a struggling Silicon Valley startup featured in Patrick Lencioni's book \"The Five Dysfunctions of a Team.\" She faces the challenge of leading a dysfunctional executive team characterized by poor communication, lack of trust, and weak commitment. Her role involves addressing these issues to improve team dynamics and overall performance within the company.\n  \"#;\n\n  calculate_similarity_and_assert(\n    &mut test_client,\n    workspace_id,\n    answer.clone(),\n    expected_answer,\n    0.7,\n    \"expected\",\n  )\n  .await;\n}\n\n#[ignore]\n#[tokio::test]\nasync fn test_document_indexing_and_search() {\n  // Set up all the required data\n  let mut test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let object_id = uuid::Uuid::new_v4();\n\n  let collab_type = CollabType::Document;\n  let encoded_collab = {\n    let document_data = getting_started_document_data().unwrap();\n    let options = CollabOptions::new(object_id.to_string(), default_client_id());\n    let collab = Collab::new_with_options(\n      CollabOrigin::Client(CollabClient::new(\n        test_client.uid().await,\n        test_client.device_id.clone(),\n      )),\n      options,\n    )\n    .unwrap();\n    let document = Document::create_with_data(collab, document_data).unwrap();\n    document.encode_collab().unwrap()\n  };\n  test_client\n    .create_and_edit_collab_with_data(\n      object_id,\n      workspace_id,\n      collab_type,\n      Some(encoded_collab),\n      true,\n    )\n    .await;\n  test_client\n    .open_collab(workspace_id, object_id, collab_type)\n    .await;\n\n  sleep(Duration::from_millis(2000)).await;\n\n  // document should get automatically indexed after opening if it wasn't indexed before\n  let search_resp = test_client\n    .api_client\n    .search_documents(&workspace_id, \"Appflowy\", 1, 20, None)\n    .await\n    .unwrap();\n  assert_eq!(search_resp.len(), 1);\n  let item = &search_resp[0];\n  assert_eq!(item.object_id, object_id);\n\n  let preview = item.preview.clone().unwrap();\n  assert!(preview.contains(\"Welcome to AppFlowy\"));\n}\n\nasync fn create_document_collab(document_id: &str, file_name: &str) -> Document {\n  let file_path = PathBuf::from(format!(\"tests/search/asset/{}\", file_name));\n  let md = std::fs::read_to_string(file_path).unwrap();\n  let importer = MDImporter::new(None);\n  let document_data = importer.import(document_id, md).unwrap();\n  Document::create(document_id, document_data, default_client_id()).unwrap()\n}\n\nasync fn add_document_collab(\n  client: &mut TestClient,\n  workspace_id: &Uuid,\n  file_name: &str,\n  search_term: &str,\n  wait_embedding: bool,\n  uid: i64,\n) -> Uuid {\n  let object_id = Uuid::new_v4();\n  let collab = create_document_collab(&object_id.to_string(), file_name).await;\n  println!(\"create document with content: {:?}\", collab.paragraphs());\n  let encoded = collab.encode_collab().unwrap();\n  client\n    .create_collab_with_data(*workspace_id, object_id, CollabType::Document, encoded)\n    .await\n    .unwrap();\n  client\n    .insert_view_to_general_space(\n      workspace_id,\n      &object_id.to_string(),\n      search_term,\n      ViewLayout::Document,\n      uid,\n    )\n    .await;\n  if wait_embedding {\n    client\n      .wait_until_get_embedding(workspace_id, &object_id)\n      .await\n      .unwrap();\n  }\n  object_id\n}\n\nasync fn create_chat_and_ask_question(\n  test_client: &mut TestClient,\n  workspace_id: &Uuid,\n  rag_id: uuid::Uuid,\n  chat_name: &str,\n  question: &str,\n) -> String {\n  // Create a chat\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let params = CreateChatParams {\n    chat_id: chat_id.clone(),\n    name: chat_name.to_string(),\n    rag_ids: vec![rag_id],\n  };\n\n  test_client\n    .api_client\n    .create_chat(workspace_id, params)\n    .await\n    .unwrap();\n\n  // Ask question and get answer\n  let params = CreateChatMessageParams::new_user(question);\n  let question = test_client\n    .api_client\n    .create_question(workspace_id, &chat_id, params)\n    .await\n    .unwrap();\n  let answer_stream = test_client\n    .api_client\n    .stream_answer_v2(workspace_id, &chat_id, question.message_id)\n    .await\n    .unwrap();\n  collect_answer(answer_stream).await\n}\n\nasync fn calculate_similarity_and_assert(\n  test_client: &mut TestClient,\n  workspace_id: Uuid,\n  input: String,\n  expected: &str,\n  threshold: f64,\n  error_message: &str,\n) -> f64 {\n  let params = CalculateSimilarityParams {\n    workspace_id,\n    input: input.clone(),\n    expected: expected.to_string(),\n    use_embedding: true,\n  };\n\n  let score = test_client\n    .api_client\n    .calculate_similarity(params)\n    .await\n    .unwrap()\n    .score;\n\n  assert!(\n    score > threshold,\n    \"{} should greater than {}, but got: {}. input:{}, expected: {}\",\n    error_message,\n    threshold,\n    score,\n    input,\n    expected\n  );\n\n  score\n}\n"
  },
  {
    "path": "tests/search/mod.rs",
    "content": "mod document_search;\n"
  },
  {
    "path": "tests/server_info/info.rs",
    "content": "use client_api_test::generate_unique_registered_user_client;\n\n#[tokio::test]\nasync fn test_get_server_info() {\n  let (c, _) = generate_unique_registered_user_client().await;\n  c.get_server_info()\n    .await\n    .expect(\"Failed to get server info\");\n}\n"
  },
  {
    "path": "tests/server_info/mod.rs",
    "content": "mod info;\n"
  },
  {
    "path": "tests/sql_test/chat_test.rs",
    "content": "use crate::sql_test::util::{create_test_user, setup_db};\nuse database::chat::chat_ops::{\n  delete_chat, get_all_chat_messages, insert_chat, insert_question_message, select_chat,\n  select_chat_messages, select_chat_settings, update_chat_settings,\n};\nuse serde_json::json;\nuse shared_entity::dto::chat_dto::{\n  ChatAuthorType, ChatAuthorWithUuid, CreateChatParams, GetChatMessageParams,\n};\n\nuse shared_entity::dto::chat_dto::UpdateChatParams;\nuse sqlx::PgPool;\nuse uuid::Uuid;\n\n#[sqlx::test(migrations = false)]\nasync fn chat_crud_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let rag_id_1 = Uuid::new_v4();\n  let rag_id_2 = Uuid::new_v4();\n  // create chat\n  {\n    insert_chat(\n      &pool,\n      &user.workspace_id,\n      CreateChatParams {\n        chat_id: chat_id.clone(),\n        name: \"my first chat\".to_string(),\n        rag_ids: vec![rag_id_1, rag_id_2],\n      },\n    )\n    .await\n    .unwrap();\n  }\n\n  // get chat\n  {\n    let chat = select_chat(&pool, &chat_id).await.unwrap();\n    assert_eq!(chat.name, \"my first chat\");\n    assert_eq!(\n      chat.rag_ids,\n      json!(vec![rag_id_1.to_string(), rag_id_2.to_string()]),\n    );\n  }\n\n  // delete chat\n  {\n    let mut txn = pool.begin().await.unwrap();\n    delete_chat(&mut txn, &chat_id).await.unwrap();\n    txn.commit().await.unwrap();\n  }\n\n  // get chat\n  {\n    let result = select_chat(&pool, &chat_id).await.unwrap_err();\n    assert!(result.is_record_not_found());\n  }\n}\n\n#[sqlx::test(migrations = false)]\nasync fn chat_message_crud_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let chat_id = uuid::Uuid::new_v4().to_string();\n  let rag_id_1 = Uuid::new_v4();\n  let rag_id_2 = Uuid::new_v4();\n  // create chat\n  {\n    insert_chat(\n      &pool,\n      &user.workspace_id,\n      CreateChatParams {\n        chat_id: chat_id.clone(),\n        name: \"my first chat\".to_string(),\n        rag_ids: vec![rag_id_1, rag_id_2],\n      },\n    )\n    .await\n    .unwrap();\n  }\n\n  // create chat messages\n  let uid = 0;\n  let user_uuid = Uuid::new_v4();\n  for i in 0..5 {\n    let _ = insert_question_message(\n      &pool,\n      ChatAuthorWithUuid::new(uid, user_uuid, ChatAuthorType::System),\n      &chat_id,\n      format!(\"message {}\", i),\n    )\n    .await\n    .unwrap();\n  }\n  {\n    let params = GetChatMessageParams::next_back(3);\n    let mut txn = pool.begin().await.unwrap();\n    let result = select_chat_messages(&mut txn, &chat_id, params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n    assert_eq!(result.messages.len(), 3);\n    assert_eq!(result.messages[0].message_id, 5);\n    assert_eq!(result.messages[1].message_id, 4);\n    assert_eq!(result.messages[2].message_id, 3);\n    assert!(result.has_more);\n  }\n\n  // get 3 messages: 1,2,3\n  {\n    // option 1:use offset to get 3 messages => 1,2,3\n    let mut txn = pool.begin().await.unwrap();\n    let params = GetChatMessageParams::offset(0, 3);\n    let result_1 = select_chat_messages(&mut txn, &chat_id, params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n    assert_eq!(result_1.messages.len(), 3);\n    assert_eq!(result_1.messages[0].message_id, 1);\n    assert_eq!(result_1.messages[1].message_id, 2);\n    assert_eq!(result_1.messages[2].message_id, 3);\n    assert_eq!(result_1.total, 5);\n    assert!(result_1.has_more);\n\n    // option 2:use before_message_id to get 3 messages => 1,2,3\n    let params = GetChatMessageParams::before_message_id(4, 3);\n    let mut txn = pool.begin().await.unwrap();\n    let result_2 = select_chat_messages(&mut txn, &chat_id, params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n    assert_eq!(result_2.messages.len(), 3);\n    assert_eq!(result_2.messages[0].message_id, 3);\n    assert_eq!(result_2.messages[1].message_id, 2);\n    assert_eq!(result_2.messages[2].message_id, 1);\n    assert_eq!(result_2.total, 5);\n    assert!(!result_2.has_more);\n  }\n\n  // get two messages: 4,5\n  {\n    // option 1:use offset to get 2 messages => 4,5\n    let params = GetChatMessageParams::offset(3, 3);\n    let mut txn = pool.begin().await.unwrap();\n    let result_1 = select_chat_messages(&mut txn, &chat_id, params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n    assert_eq!(result_1.messages.len(), 2);\n    assert_eq!(result_1.messages[0].message_id, 4);\n    assert_eq!(result_1.messages[1].message_id, 5);\n    assert_eq!(result_1.total, 5);\n    assert!(!result_1.has_more);\n\n    // option 2:use after_message_id to get remaining 2 messages => 4,5\n    let params = GetChatMessageParams::after_message_id(3, 3);\n    let mut txn = pool.begin().await.unwrap();\n    let result_2 = select_chat_messages(&mut txn, &chat_id, params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n    assert_eq!(result_2.messages.len(), 2);\n    assert_eq!(result_2.messages[0].message_id, 5);\n    assert_eq!(result_2.messages[1].message_id, 4);\n    assert_eq!(result_2.total, 5);\n    assert!(!result_2.has_more);\n  }\n\n  // get all messages\n  {\n    let messages = get_all_chat_messages(&pool, &chat_id).await.unwrap();\n    assert_eq!(messages.len(), 5);\n  }\n}\n\n#[sqlx::test(migrations = false)]\nasync fn chat_setting_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n  let workspace_id = user.workspace_id;\n  let chat_id = uuid::Uuid::new_v4();\n  let rag1 = Uuid::new_v4();\n  let rag2 = Uuid::new_v4();\n\n  // Insert initial chat data with rag_ids\n  let insert_params = CreateChatParams {\n    chat_id: chat_id.to_string(),\n    name: \"Initial Chat\".to_string(),\n    rag_ids: vec![rag1, rag2],\n  };\n\n  insert_chat(&pool, &workspace_id, insert_params)\n    .await\n    .expect(\"Failed to insert chat\");\n\n  // Validate inserted rag_ids\n  let settings = select_chat_settings(&pool, &chat_id)\n    .await\n    .expect(\"Failed to get chat settings\");\n  assert_eq!(settings.rag_ids, vec![rag1.to_string(), rag2.to_string()]);\n\n  // Update metadata\n  let update_params = UpdateChatParams {\n    name: None,\n    metadata: Some(json!({\"key\": \"value\"})),\n    rag_ids: None,\n  };\n\n  update_chat_settings(&pool, &chat_id, update_params)\n    .await\n    .expect(\"Failed to update chat settings\");\n\n  // Validate metadata update\n  let settings = select_chat_settings(&pool, &chat_id)\n    .await\n    .expect(\"Failed to get chat settings\");\n  assert_eq!(settings.metadata, json!({\"key\": \"value\"}));\n\n  // Update rag_ids and metadata together\n  let rag3 = Uuid::new_v4();\n  let rag4 = Uuid::new_v4();\n  let update_params = UpdateChatParams {\n    name: None,\n    metadata: Some(json!({\"new_key\": \"new_value\"})),\n    rag_ids: Some(vec![rag3.to_string(), rag4.to_string()]),\n  };\n\n  update_chat_settings(&pool, &chat_id, update_params)\n    .await\n    .expect(\"Failed to update chat settings\");\n\n  // Validate both rag_ids and metadata\n  let settings = select_chat_settings(&pool, &chat_id)\n    .await\n    .expect(\"Failed to get chat settings\");\n  assert_eq!(\n    settings.metadata,\n    json!({\"key\": \"value\", \"new_key\": \"new_value\"})\n  );\n  assert_eq!(settings.rag_ids, vec![rag3.to_string(), rag4.to_string()]);\n}\n"
  },
  {
    "path": "tests/sql_test/collab_embed_test.rs",
    "content": "use crate::sql_test::util::{\n  create_test_collab_document, create_test_user, select_all_fragments, setup_db, upsert_test_chunks,\n};\nuse appflowy_ai_client::dto::EmbeddingModel;\nuse indexer::collab_indexer::split_text_into_chunks;\nuse sqlx::PgPool;\n\n// Book content broken into logical chunks for testing\nconst TEST_CHUNKS: [&str; 6] = [\n    \"The Five Dysfunctions of a Team\",\n    \"Part I: Underachievement - Introduces Kathryn Petersen, the newly appointed CEO of DecisionTech, a struggling Silicon Valley startup with a dysfunctional executive team.\",\n    \"Part II: Lighting the Fire - Kathryn organizes an offsite meeting to build trust and introduce constructive conflict, encouraging open discussion about disagreements.\",\n    \"Part IV: Traction - The team experiences benefits of improved trust and open conflict, with accountability becoming routine and meetings increasingly productive.\",\n    \"The Model identifies five key dysfunctions: Absence of Trust, Fear of Conflict, Lack of Commitment, Avoidance of Accountability, and Inattention to Results.\",\n    \"The book provides practical strategies for building trust, encouraging conflict, ensuring commitment, embracing accountability, and focusing on collective results.\"\n];\n\n#[sqlx::test(migrations = false)]\nasync fn insert_collab_embedding_fragment_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n  let mut paragraphs = TEST_CHUNKS\n    .iter()\n    .map(|&s| s.to_string())\n    .collect::<Vec<_>>();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let doc_id = uuid::Uuid::new_v4();\n  let workspace_id = user.workspace_id;\n  create_test_collab_document(&pool, &user.uid, &workspace_id, &doc_id).await;\n\n  let chunks_1 = split_text_into_chunks(\n    doc_id,\n    paragraphs.clone(),\n    EmbeddingModel::TextEmbedding3Small,\n    500,\n    100,\n  )\n  .unwrap();\n\n  upsert_test_chunks(&pool, &workspace_id, &doc_id, chunks_1.clone()).await;\n  let fragments_1 = select_all_fragments(&pool, &doc_id).await;\n  assert_eq!(chunks_1.len(), fragments_1.len());\n\n  // simulate edit first paragraph.\n  paragraphs[0].push_str(\" by Patrick Lencioni is a compelling exploration of team dynamics and the common pitfalls that undermine successful collaboration.\");\n  let chunks_2 = split_text_into_chunks(\n    doc_id,\n    paragraphs.clone(),\n    EmbeddingModel::TextEmbedding3Small,\n    500,\n    100,\n  )\n  .unwrap();\n\n  // After edit, the first paragraph will be different. but the second paragraph will be the same.\n  assert_ne!(chunks_1[0].fragment_id, chunks_2[0].fragment_id);\n  assert_eq!(chunks_1[1].fragment_id, chunks_2[1].fragment_id);\n  assert_eq!(chunks_2.len(), 2);\n\n  // Simulate insert a new paragraph\n  paragraphs.insert(3, \"Part III: Heavy Lifting - Focuses on accountability and responsibility, with Kathryn holding the team to high standards and addressing issues directly.\".to_string(),);\n  let chunks_3 = split_text_into_chunks(\n    doc_id,\n    paragraphs.clone(),\n    EmbeddingModel::TextEmbedding3Small,\n    500,\n    100,\n  )\n  .unwrap();\n\n  // After insert a new paragraph, the second paragraph will be different, but the first will remain the same.\n  assert_eq!(chunks_2[0].fragment_id, chunks_3[0].fragment_id);\n  assert_ne!(chunks_2[1].fragment_id, chunks_3[1].fragment_id);\n  assert_eq!(chunks_3.len(), 3);\n}\n\n#[sqlx::test(migrations = false)]\nasync fn test_embed_over_context_size(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let doc_id = uuid::Uuid::new_v4();\n  let workspace_id = user.workspace_id;\n  create_test_collab_document(&pool, &user.uid, &workspace_id, &doc_id).await;\n  let content= \"The Five Dysfunctions of a Team Part I: Underachievement - Introduces Kathryn Petersen, the newly appointed CEO of DecisionTech, a struggling Silicon Valley startup with a dysfunctional executive team. Part II: Lighting the Fire - Kathryn organizes an offsite meeting to build trust and introduce constructive conflict, encouraging open discussion about disagreements. Part IV: Traction - The team experiences benefits of improved trust and open conflict, with accountability becoming routine and meetings increasingly productive. The Model identifies five key dysfunctions: Absence of Trust, Fear of Conflict, Lack of Commitment, Avoidance of Accountability, and Inattention to Results. The book provides practical strategies for building trust, encouraging conflict, ensuring commitment, embracing accountability, and focusing on collective results.\";\n  let chunks = split_text_into_chunks(\n    doc_id,\n    vec![content.to_string()],\n    EmbeddingModel::TextEmbedding3Small,\n    300,\n    100,\n  )\n  .unwrap();\n\n  assert_eq!(chunks.len(), 5);\n  upsert_test_chunks(&pool, &workspace_id, &doc_id, chunks.clone()).await;\n  let fragments = select_all_fragments(&pool, &doc_id).await;\n  assert_eq!(chunks.len(), fragments.len());\n\n  // Replace the content with a new one. It will cause all existing fragments to be deleted.\n  let content = \"Hello world!\";\n  let chunks = split_text_into_chunks(\n    doc_id,\n    vec![content.to_string()],\n    EmbeddingModel::TextEmbedding3Small,\n    300,\n    100,\n  )\n  .unwrap();\n  upsert_test_chunks(&pool, &workspace_id, &doc_id, chunks.clone()).await;\n  let fragments = select_all_fragments(&pool, &doc_id).await;\n  assert_eq!(fragments.len(), 1);\n}\n"
  },
  {
    "path": "tests/sql_test/history_test.rs",
    "content": "use crate::sql_test::util::{create_test_user, setup_db};\nuse collab_entity::CollabType;\nuse database::history::ops::{\n  get_latest_snapshot, get_latest_snapshot_state, get_snapshot_meta_list, insert_history,\n};\nuse sqlx::PgPool;\nuse tonic_proto::history::SnapshotMetaPb;\nuse uuid::Uuid;\n\n#[sqlx::test(migrations = false)]\nasync fn insert_snapshot_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let workspace_id = user.workspace_id;\n  let timestamp = chrono::Utc::now().timestamp();\n  let object_id = Uuid::new_v4();\n  let collab_type = CollabType::Document;\n\n  let snapshots = vec![\n    SnapshotMetaPb {\n      oid: object_id.to_string(),\n      snapshot: vec![1, 2, 3],\n      snapshot_version: 1,\n      created_at: timestamp,\n    },\n    SnapshotMetaPb {\n      oid: object_id.to_string(),\n      snapshot: vec![3, 4, 5],\n      snapshot_version: 1,\n      created_at: timestamp + 100,\n    },\n  ];\n\n  let doc_state = vec![10, 11, 12];\n  let doc_state_version = 1;\n  let deps_snapshot_id = None;\n\n  insert_history(\n    &workspace_id,\n    &object_id,\n    doc_state,\n    doc_state_version,\n    deps_snapshot_id,\n    collab_type,\n    timestamp + 200,\n    snapshots,\n    pool.clone(),\n  )\n  .await\n  .unwrap();\n\n  let snapshot_list = get_snapshot_meta_list(&object_id, &collab_type, &pool)\n    .await\n    .unwrap();\n  assert_eq!(snapshot_list.len(), 2);\n  assert_eq!(snapshot_list[0].snapshot, vec![3, 4, 5]);\n  assert_eq!(snapshot_list[1].snapshot, vec![1, 2, 3]);\n\n  let snapshot_meta = get_latest_snapshot_state(&object_id, timestamp, &collab_type, &pool)\n    .await\n    .unwrap()\n    .unwrap();\n  assert_eq!(snapshot_meta.doc_state, vec![10, 11, 12]);\n\n  // Get the latest snapshot\n  let snapshot = get_latest_snapshot(&object_id, &collab_type, &pool)\n    .await\n    .unwrap()\n    .unwrap();\n  assert_eq!(snapshot.history_state.unwrap().doc_state, vec![10, 11, 12]);\n  assert_eq!(snapshot.snapshot_meta.unwrap().snapshot, vec![3, 4, 5]);\n}\n"
  },
  {
    "path": "tests/sql_test/mod.rs",
    "content": "mod chat_test;\nmod collab_embed_test;\nmod history_test;\npub(crate) mod util;\nmod workspace_test;\n"
  },
  {
    "path": "tests/sql_test/util.rs",
    "content": "use bytes::Bytes;\nuse collab::core::collab::default_client_id;\nuse collab_document::document_data::default_document_collab_data;\nuse collab_entity::CollabType;\nuse database::collab::insert_into_af_collab;\nuse database::index::{get_collab_embedding_fragment, upsert_collab_embeddings, Fragment};\nuse database_entity::dto::{AFCollabEmbeddedChunk, CollabParams};\nuse lazy_static::lazy_static;\nuse rand::distributions::Alphanumeric;\nuse rand::{thread_rng, Rng};\nuse snowflake::Snowflake;\nuse sqlx::PgPool;\nuse tokio::sync::RwLock;\nuse uuid::Uuid;\n\npub async fn setup_db(pool: &PgPool) -> anyhow::Result<()> {\n  // Have to manually create schema and tables managed by gotrue but referenced by our\n  // migration scripts.\n  sqlx::query(r#\"create schema auth\"#).execute(pool).await?;\n  sqlx::query(\n    r#\"\n      CREATE TABLE auth.users(\n        id uuid NOT NULL UNIQUE,\n        deleted_at timestamptz null,\n        CONSTRAINT users_pkey PRIMARY KEY (id)\n      )\n    \"#,\n  )\n  .execute(pool)\n  .await?;\n\n  sqlx::migrate!(\"./migrations\")\n    .set_ignore_missing(true)\n    .run(pool)\n    .await\n    .unwrap();\n  Ok(())\n}\n\npub async fn insert_auth_user(pool: &PgPool, user_uuid: Uuid) -> anyhow::Result<()> {\n  sqlx::query(\n    r#\"\n      INSERT INTO auth.users (id)\n      VALUES ($1)\n    \"#,\n  )\n  .bind(user_uuid)\n  .execute(pool)\n  .await?;\n  Ok(())\n}\n\nlazy_static! {\n  pub static ref ID_GEN: RwLock<Snowflake> = RwLock::new(Snowflake::new(1));\n}\n\npub async fn create_test_user(\n  pool: &PgPool,\n  user_uuid: Uuid,\n  email: &str,\n  name: &str,\n) -> anyhow::Result<TestUser> {\n  insert_auth_user(pool, user_uuid).await.unwrap();\n  let uid = ID_GEN.write().await.next_id();\n  let workspace_id = database::user::create_user(pool, uid, &user_uuid, email, name)\n    .await\n    .unwrap();\n\n  Ok(TestUser { uid, workspace_id })\n}\n\npub async fn create_test_collab_document(\n  pg_pool: &PgPool,\n  uid: &i64,\n  workspace_id: &Uuid,\n  doc_id: &Uuid,\n) {\n  let document = default_document_collab_data(&doc_id.to_string(), default_client_id()).unwrap();\n  let params = CollabParams {\n    object_id: *doc_id,\n    encoded_collab_v1: Bytes::from(document.encode_to_bytes().unwrap()),\n    collab_type: CollabType::Document,\n    updated_at: None,\n  };\n\n  let mut txn = pg_pool.begin().await.unwrap();\n  insert_into_af_collab(&mut txn, uid, workspace_id, &params)\n    .await\n    .unwrap();\n  txn.commit().await.unwrap();\n}\n\npub async fn upsert_test_chunks(\n  pg: &PgPool,\n  workspace_id: &Uuid,\n  doc_id: &Uuid,\n  chunks: Vec<AFCollabEmbeddedChunk>,\n) {\n  let mut txn = pg.begin().await.unwrap();\n  upsert_collab_embeddings(&mut txn, workspace_id, doc_id, 0, chunks.clone())\n    .await\n    .unwrap();\n  txn.commit().await.unwrap();\n}\n\npub async fn select_all_fragments(pg: &PgPool, object_id: &Uuid) -> Vec<Fragment> {\n  get_collab_embedding_fragment(pg, object_id).await.unwrap()\n}\n\n#[derive(Clone)]\npub struct TestUser {\n  pub uid: i64,\n  pub workspace_id: Uuid,\n}\n\npub fn generate_random_bytes(size: usize) -> Vec<u8> {\n  let s: String = thread_rng()\n    .sample_iter(&Alphanumeric)\n    .take(size)\n    .map(char::from)\n    .collect();\n  s.into_bytes()\n}\n"
  },
  {
    "path": "tests/sql_test/workspace_test.rs",
    "content": "use crate::sql_test::util::{create_test_user, generate_random_bytes, setup_db};\nuse chrono::Utc;\n\nuse collab_entity::CollabType;\nuse database::collab::{\n  insert_into_af_collab, insert_into_af_collab_bulk_for_user, select_blob_from_af_collab,\n  select_collab_meta_from_af_collab,\n};\nuse database_entity::dto::CollabParams;\nuse sqlx::PgPool;\n\n#[sqlx::test(migrations = false)]\nasync fn insert_collab_sql_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let mut object_ids = vec![];\n\n  let data_sizes = vec![\n    5120,    // 5 KB\n    10240,   // 10 KB\n    102400,  // 100 KB\n    512000,  // 500 KB\n    5120000, // 5 MB\n  ];\n  let start_time = std::time::Instant::now();\n  let updated_at = Utc::now();\n  for &data_size in &data_sizes {\n    let encoded_collab_v1 = generate_random_bytes(data_size);\n    let object_id = uuid::Uuid::new_v4();\n    object_ids.push(object_id);\n    let mut txn = pool.begin().await.unwrap();\n    let params = CollabParams {\n      object_id,\n      collab_type: CollabType::Unknown,\n      encoded_collab_v1: encoded_collab_v1.into(),\n      updated_at: Some(updated_at),\n    };\n    insert_into_af_collab(&mut txn, &user.uid, &user.workspace_id, &params)\n      .await\n      .unwrap();\n    txn.commit().await.unwrap();\n  }\n  let duration = start_time.elapsed();\n  println!(\"Insert time: {:?}\", duration);\n\n  for object_id in object_ids {\n    let meta = select_collab_meta_from_af_collab(&pool, &object_id, &CollabType::Unknown)\n      .await\n      .unwrap()\n      .unwrap();\n\n    assert_eq!(meta.oid, object_id.to_string());\n    assert_eq!(meta.workspace_id, user.workspace_id);\n    assert_eq!(\n      meta.updated_at.timestamp_millis(),\n      updated_at.timestamp_millis()\n    );\n    assert!(meta.created_at.is_some());\n    assert!(meta.deleted_at.is_none());\n  }\n}\n#[sqlx::test(migrations = false)]\nasync fn insert_bulk_collab_sql_test(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let name = user_uuid.to_string();\n  let email = format!(\"{}@appflowy.io\", name);\n  let user = create_test_user(&pool, user_uuid, &email, &name)\n    .await\n    .unwrap();\n\n  let mut object_ids = vec![];\n  let data_sizes = vec![\n    5120,    // 5 KB\n    10240,   // 10 KB\n    102400,  // 100 KB\n    512000,  // 500 KB\n    5120000, // 5 MB\n  ];\n  let mut collab_params_list = vec![];\n  let mut original_data_list = vec![]; // Store original data for validation\n\n  // Prepare bulk insert data\n  for &data_size in &data_sizes {\n    let encoded_collab_v1 = generate_random_bytes(data_size);\n    let object_id = uuid::Uuid::new_v4();\n    object_ids.push(object_id);\n\n    let params = CollabParams {\n      object_id,\n      collab_type: CollabType::Unknown,\n      encoded_collab_v1: encoded_collab_v1.clone().into(), // Store the original data for validation\n      updated_at: None,\n    };\n\n    collab_params_list.push(params);\n    original_data_list.push(encoded_collab_v1); // Keep track of original data\n  }\n\n  // Perform bulk insert\n  let start_time = std::time::Instant::now(); // Start timing\n  let mut txn = pool.begin().await.unwrap();\n  insert_into_af_collab_bulk_for_user(&mut txn, &user.uid, user.workspace_id, &collab_params_list)\n    .await\n    .unwrap();\n  txn.commit().await.unwrap();\n  let duration = start_time.elapsed();\n  println!(\"Bulk insert time: {:?}\", duration);\n\n  // Validate inserted data\n  for (i, object_id) in object_ids.iter().enumerate() {\n    let (_, inserted_data) = select_blob_from_af_collab(&pool, &CollabType::Unknown, object_id)\n      .await\n      .unwrap();\n\n    // Ensure the inserted data matches the original data\n    let original_data = &original_data_list[i];\n    assert_eq!(\n      inserted_data, *original_data,\n      \"Data mismatch for object_id: {}\",\n      object_id\n    );\n    println!(\n      \"Validated data size: {} bytes for object_id: {}\",\n      original_data.len(),\n      object_id\n    );\n  }\n}\n\n#[sqlx::test(migrations = false)]\nasync fn test_bulk_insert_empty_collab_list(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let user = create_test_user(&pool, user_uuid, \"test@appflowy.io\", \"test_user\")\n    .await\n    .unwrap();\n\n  let collab_params_list: Vec<CollabParams> = vec![]; // Empty list\n  let mut txn = pool.begin().await.unwrap();\n  let result = insert_into_af_collab_bulk_for_user(\n    &mut txn,\n    &user.uid,\n    user.workspace_id,\n    &collab_params_list,\n  )\n  .await;\n  assert!(result.is_ok());\n  txn.commit().await.unwrap();\n}\n\n#[sqlx::test(migrations = false)]\nasync fn test_bulk_insert_duplicate_oid_partition_key(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let user = create_test_user(&pool, user_uuid, \"test@appflowy.io\", \"test_user\")\n    .await\n    .unwrap();\n\n  let object_id = uuid::Uuid::new_v4();\n  let encoded_collab_v1 = generate_random_bytes(1024); // 1KB of random data\n\n  // Two items with the same oid and partition_key\n  let collab_params_list = vec![\n    CollabParams {\n      object_id,\n      collab_type: CollabType::Unknown,\n      encoded_collab_v1: encoded_collab_v1.clone().into(),\n      updated_at: None,\n    },\n    CollabParams {\n      object_id, // Duplicate oid\n      collab_type: CollabType::Unknown,\n      encoded_collab_v1: generate_random_bytes(2048).into(), // Different data to test update\n      updated_at: None,\n    },\n  ];\n\n  let mut txn = pool.begin().await.unwrap();\n  insert_into_af_collab_bulk_for_user(&mut txn, &user.uid, user.workspace_id, &collab_params_list)\n    .await\n    .unwrap();\n  txn.commit().await.unwrap();\n\n  // Validate the data was updated, not duplicated\n  let (_, data) = select_blob_from_af_collab(&pool, &CollabType::Unknown, &object_id)\n    .await\n    .unwrap();\n  assert_eq!(data, encoded_collab_v1); // should equal the data that insert first time\n}\n\n#[sqlx::test(migrations = false)]\nasync fn test_batch_insert_comparison(pool: PgPool) {\n  setup_db(&pool).await.unwrap();\n\n  let user_uuid = uuid::Uuid::new_v4();\n  let user = create_test_user(&pool, user_uuid, \"test@appflowy.io\", \"test_user\")\n    .await\n    .unwrap();\n\n  // Define the different test cases\n  let row_sizes = vec![1024, 5 * 1024]; // 1KB and 5KB row sizes\n  let total_rows = vec![500, 1000, 2000, 3000, 6000]; // Number of rows\n  let chunk_sizes = vec![2000]; // Chunk size for batch inserts\n\n  // Iterate over the different row sizes\n  for row_size in row_sizes {\n    // Iterate over the different total row counts\n    for &total_row_count in &total_rows {\n      // Generate data for the total row count\n      let collab_params_list: Vec<CollabParams> = (0..total_row_count)\n        .map(|_| CollabParams {\n          object_id: uuid::Uuid::new_v4(),\n          collab_type: CollabType::Unknown,\n          encoded_collab_v1: generate_random_bytes(row_size).into(), // Generate random bytes for the given row size\n          updated_at: None,\n        })\n        .collect();\n\n      // Group the results for readability\n      println!(\"\\n==============================\");\n      println!(\n        \"Row Size: {}KB, Total Rows: {}\",\n        row_size / 1024,\n        total_row_count\n      );\n\n      // === Test Case 1: Insert all rows in one batch ===\n      let start_time = std::time::Instant::now();\n      let mut txn = pool.begin().await.unwrap();\n      let result = insert_into_af_collab_bulk_for_user(\n        &mut txn,\n        &user.uid,\n        user.workspace_id,\n        &collab_params_list,\n      )\n      .await;\n\n      assert!(result.is_ok()); // Ensure the insert doesn't fail\n      txn.commit().await.unwrap();\n      let total_time_single_batch = start_time.elapsed();\n      println!(\n        \"Batch Insert - Time for inserting {} rows of size {}KB in one batch: {:?}\",\n        total_row_count,\n        row_size / 1024,\n        total_time_single_batch\n      );\n\n      // === Test Case 2: Insert rows in chunks ===\n      for &chunk_size in &chunk_sizes {\n        let mut total_time_multiple_batches = std::time::Duration::new(0, 0);\n        for chunk in collab_params_list.chunks(chunk_size) {\n          let start_time = std::time::Instant::now();\n          let mut txn = pool.begin().await.unwrap();\n          let result =\n            insert_into_af_collab_bulk_for_user(&mut txn, &user.uid, user.workspace_id, chunk)\n              .await;\n\n          assert!(result.is_ok()); // Ensure the insert doesn't fail\n          txn.commit().await.unwrap();\n          total_time_multiple_batches += start_time.elapsed();\n        }\n        println!(\n          \"Chunked Insert - Time for inserting {} rows of size {}KB in {}-row chunks: {:?}\",\n          total_row_count,\n          row_size / 1024,\n          chunk_size,\n          total_time_multiple_batches\n        );\n      }\n\n      println!(\"==============================\\n\");\n    }\n  }\n}\n"
  },
  {
    "path": "tests/user/delete.rs",
    "content": "use client_api::entity::AFRole;\nuse client_api_test::*;\nuse gotrue::params::{AdminDeleteUserParams, AdminUserParams};\n\n#[tokio::test]\nasync fn user_delete_self() {\n  let (client, user) = generate_unique_registered_user_client().await;\n  let admin_client = admin_user_client().await;\n  {\n    // user found before deletion\n    let search_result = admin_client\n      .admin_list_users(Some(&user.email))\n      .await\n      .unwrap();\n    let _target_user = search_result\n      .into_iter()\n      .find(|u| u.email == user.email)\n      .unwrap();\n  }\n\n  client.delete_user().await.unwrap();\n\n  {\n    // user cannot be found after deletion\n    let search_result = admin_client\n      .admin_list_users(Some(&user.email))\n      .await\n      .unwrap();\n    let target_user = search_result.into_iter().find(|u| u.email == user.email);\n    assert!(target_user.is_none(), \"User should be deleted: {:?}\", user);\n  }\n}\n\n/// Scenario:\n/// - User1 owns WorkspaceA\n/// - User1 invites User2 to WorkspaceA\n/// - User2 deletes itself\n/// - WorkspaceA should still exist\n#[tokio::test]\nasync fn user_delete_self_shared_workspace() {\n  let user_1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_a = user_1.workspace_id().await;\n  let user_2 = TestClient::new_user_without_ws_conn().await;\n  user_1\n    .invite_and_accepted_workspace_member(&workspace_a, &user_2, AFRole::Member)\n    .await\n    .unwrap();\n  user_2.api_client.delete_user().await.unwrap();\n  let user_1_workspaces = user_1.api_client.get_workspaces().await.unwrap();\n  let _workspace_a = user_1_workspaces\n    .into_iter()\n    .find(|w| w.workspace_id == workspace_a)\n    .unwrap();\n}\n\n#[tokio::test]\nasync fn admin_delete_create_same_user_hard() {\n  let (client, user) = generate_unique_registered_user_client().await;\n  let workspaces = client.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let user_uuid = client.get_profile().await.unwrap().uuid;\n\n  let admin_token = {\n    let admin_client = admin_user_client().await;\n    admin_client.access_token().unwrap()\n  };\n\n  // Delete user as admin\n  let gotrue_client: gotrue::api::Client = localhost_gotrue_client();\n  gotrue_client\n    .admin_delete_user(\n      &admin_token,\n      &user_uuid.to_string(),\n      &AdminDeleteUserParams {\n        should_soft_delete: false,\n      },\n    )\n    .await\n    .unwrap();\n\n  // Recreate same user\n  gotrue_client\n    .admin_add_user(\n      &admin_token,\n      &AdminUserParams {\n        email: user.email.clone(),\n        password: Some(user.password.clone()),\n        email_confirm: true,\n        ..Default::default()\n      },\n    )\n    .await\n    .unwrap();\n\n  // Login with recreated user\n  let client = localhost_client();\n  client\n    .sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n  let recreated_user_uuid = client.get_profile().await.unwrap().uuid;\n  let recreated_workspace_uuid = client.get_workspaces().await.unwrap()[0].workspace_id;\n  assert_ne!(user_uuid, recreated_user_uuid);\n  assert_ne!(workspace_id, recreated_workspace_uuid);\n}\n"
  },
  {
    "path": "tests/user/image.rs",
    "content": "use client_api_test::generate_unique_registered_user_client;\n\n#[tokio::test]\nasync fn upload_and_retrieve_image_asset() {\n  let (client, _) = generate_unique_registered_user_client().await;\n  let asset_source = client\n    .upload_user_image_asset(\"tests/user/asset/avatar.png\")\n    .await\n    .unwrap();\n  let file_id = asset_source.file_id;\n  let person_id = client.get_profile().await.unwrap().uuid;\n  client\n    .get_user_image_asset(&person_id, &file_id)\n    .await\n    .unwrap();\n}\n"
  },
  {
    "path": "tests/user/mod.rs",
    "content": "mod delete;\nmod image;\nmod refresh;\nmod sign_in;\nmod sign_out;\nmod sign_up;\nmod update;\nmod user_awareness_test;\n"
  },
  {
    "path": "tests/user/refresh.rs",
    "content": "use app_error::AppError;\nuse client_api_test::generate_unique_registered_user_client;\nuse futures::future::join_all;\nuse std::time::SystemTime;\n\n#[tokio::test]\nasync fn refresh_success() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let old_token = c.access_token().unwrap();\n  tokio::time::sleep(std::time::Duration::from_secs(2)).await;\n  c.refresh_token(\"\").await.unwrap();\n  let new_token = c.access_token().unwrap();\n  assert_ne!(old_token, new_token);\n}\n\n#[tokio::test]\nasync fn concurrent_refresh() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let old_token = c.access_token().unwrap();\n  tokio::time::sleep(std::time::Duration::from_secs(2)).await;\n\n  let mut join_handles = vec![];\n  for _ in 0..20 {\n    let cloned_client = c.clone();\n    let handle = tokio::spawn(async move {\n      cloned_client.refresh_token(\"\").await.unwrap();\n      Ok::<(), AppError>(())\n    });\n    join_handles.push(handle);\n  }\n  let results = join_all(join_handles).await;\n  assert_eq!(results.len(), 20);\n  for result in results {\n    result.unwrap().unwrap();\n  }\n\n  let new_token = c.access_token().unwrap();\n  assert_ne!(old_token, new_token);\n}\n\n#[tokio::test]\nasync fn refresh_trigger() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  tokio::time::sleep(std::time::Duration::from_secs(2)).await;\n  let old_access_token = c.access_token().unwrap();\n\n  // Set the token to be expired\n  c.token().write().as_mut().unwrap().expires_at = SystemTime::now()\n    .duration_since(SystemTime::UNIX_EPOCH)\n    .unwrap()\n    .as_secs() as i64;\n\n  // querying that requires auth should trigger a refresh\n  let _workspaces = c.get_workspaces().await.unwrap();\n  let new_token = c.access_token().unwrap();\n\n  assert_ne!(old_access_token, new_token);\n}\n"
  },
  {
    "path": "tests/user/sign_in.rs",
    "content": "use app_error::ErrorCode;\nuse client_api_test::*;\n\n#[tokio::test]\nasync fn sign_in_unknown_user() {\n  let email = generate_unique_email();\n  let password = \"Hello123!\";\n  let c = localhost_client();\n  let err = c.sign_in_password(&email, password).await.unwrap_err();\n  assert_eq!(err.code, ErrorCode::OAuthError, \"{:?}\", err);\n  assert!(!err.message.is_empty());\n}\n\n#[tokio::test]\nasync fn sign_in_wrong_password() {\n  let c = localhost_client();\n\n  let email = generate_unique_email();\n  let password = \"Hello123!\";\n  c.sign_up(&email, password).await.unwrap();\n\n  let wrong_password = \"Hllo123!\";\n  let err = c\n    .sign_in_password(&email, wrong_password)\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::OAuthError, \"{:?}\", err);\n  assert!(!err.message.is_empty());\n}\n\n#[tokio::test]\nasync fn sign_in_unconfirmed_email() {\n  let c = localhost_client();\n\n  let email = generate_unique_email();\n  let password = \"Hello123!\";\n  c.sign_up(&email, password).await.unwrap();\n\n  let err = c.sign_in_password(&email, password).await.unwrap_err();\n  assert_eq!(err.code, ErrorCode::OAuthError, \"{:?}\", err);\n  assert!(!err.message.is_empty());\n}\n\n#[tokio::test]\nasync fn sign_in_success() {\n  let registered_user = generate_unique_registered_user().await;\n\n  {\n    // First Time\n    let c = localhost_client();\n    let is_new = c\n      .sign_in_password(&registered_user.email, &registered_user.password)\n      .await\n      .unwrap()\n      .is_new;\n    assert!(is_new);\n    assert!(c\n      .token()\n      .read()\n      .as_ref()\n      .unwrap()\n      .user\n      .confirmed_at\n      .is_some());\n\n    let workspaces = c.get_workspaces().await.unwrap();\n    assert_eq!(workspaces.len(), 1);\n    let _ = c.get_profile().await.unwrap();\n  }\n\n  {\n    // Subsequent Times\n    let c = localhost_client();\n    let is_new = c\n      .sign_in_password(&registered_user.email, &registered_user.password)\n      .await\n      .unwrap()\n      .is_new;\n    assert!(!is_new);\n\n    // workspaces should be the same\n    let workspaces = c.get_workspaces().await.unwrap();\n    assert_eq!(workspaces.len(), 1);\n  }\n}\n\n#[tokio::test]\nasync fn sign_in_with_invalid_url() {\n  let url_str = \"appflowy-flutter://#access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTQ1ODIyMjMsInN1YiI6Ijk5MGM2NDNjLTMyMWEtNGNmMi04OWY1LTNhNmJhZGFjMTg5NCIsImVtYWlsIjoiNG5uaWhpbGF0ZWRAZ21haWwuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJnb29nbGUiLCJwcm92aWRlcnMiOlsiZ29vZ2xlIl19LCJ1c2VyX21ldGFkYXRhIjp7ImF2YXRhcl91cmwiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NJdGZpa28xX0lpMmZiNzM4VnpGekViLVBqT0NCY3FUQzdrNjVIX0hnRTQwOVk9czk2LWMiLCJlbWFpbCI6IjRubmloaWxhdGVkQGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJmdWxsX25hbWUiOiJmdSB6aXhpYW5nIiwiaXNzIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vdXNlcmluZm8vdjIvbWUiLCJuYW1lIjoiZnUgeml4aWFuZyIsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NJdGZpa28xX0lpMmZiNzM4VnpGekViLVBqT0NCY3FUQzdrNjVIX0hnRTQwOVk9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTQ5OTYxMDMxOTYxNjE0NTcyNSIsInN1YiI6IjEwMTQ5OTYxMDMxOTYxNjE0NTcyNSJ9LCJyb2xlIjoiIn0.I-7j-Tdj62P56zhzEqvBc7cHMldv5MA_MM7xtrBibbE&expires_in=3600&provider_token=ya29.a0AfB_byCovXs1CUiC9_f9VBTupQPsIxwh9aSlOg0PLYJvv1x1zvVfssrQfW6_Aq9no7EKpCzFUCLElOvK1Xz4x4K5r7tug79tr5b1yiOoUMWTeWTXyV61fZHQbZ9vscAiyKYtq5NqYTiytHcQEFlKr7UMfu6BTbKsUwaCgYKAaISARISFQGOcNnC0Vsx2QCAXgYO3XbfcF91WQ0169&refresh_token=Hi3Jc3I_pj9YrexcR91i5g&token_type=bearer\";\n  let c = localhost_client();\n  match c.sign_in_with_url(url_str).await {\n    Ok(_) => panic!(\"should not be ok\"),\n    Err(e) => assert_eq!(e.code, ErrorCode::OAuthError, \"{:?}\", e),\n  }\n}\n\n#[tokio::test]\nasync fn sign_in_with_url() {\n  let c = localhost_client();\n  let email = generate_unique_email();\n  let action_link = generate_sign_in_action_link(&email).await;\n  let sign_in_url = c.extract_sign_in_url(action_link.as_str()).await.unwrap();\n  let is_new = c.sign_in_with_url(&sign_in_url).await.unwrap();\n  assert!(is_new);\n}\n\n#[tokio::test]\nasync fn sign_in_with_magic_link() {\n  let c = localhost_client();\n  let email = generate_unique_email();\n  let resp = c.sign_in_with_magic_link(&email, None).await;\n  assert!(resp.is_ok());\n}\n"
  },
  {
    "path": "tests/user/sign_out.rs",
    "content": "use client_api_test::*;\n\n#[tokio::test]\nasync fn sign_out_but_not_sign_in() {\n  let c = localhost_client();\n  let res = c.sign_out().await;\n  assert!(res.is_err());\n}\n\n#[tokio::test]\nasync fn sign_out_after_sign_in() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n  c.sign_out().await.unwrap();\n}\n"
  },
  {
    "path": "tests/user/sign_up.rs",
    "content": "use app_error::ErrorCode;\nuse client_api_test::*;\nuse gotrue_entity::dto::AuthProvider;\nuse std::time::Duration;\n\n#[tokio::test]\nasync fn sign_up_success() {\n  let email = generate_unique_email();\n  let password = \"Hello!123#\";\n  let c = localhost_client();\n  c.sign_up(&email, password).await.unwrap();\n}\n\n#[tokio::test]\nasync fn sign_up_invalid_email() {\n  let invalid_email = \"not_email_address\";\n  let password = \"Hello!123#\";\n  let error = localhost_client()\n    .sign_up(invalid_email, password)\n    .await\n    .unwrap_err();\n  assert_eq!(error.code, ErrorCode::OAuthError);\n  assert_eq!(\n    error.message,\n    \"Unable to validate email address: invalid format\"\n  );\n}\n\n#[tokio::test]\nasync fn sign_up_invalid_password() {\n  let email = generate_unique_email();\n  let password = \"3\";\n  let c = localhost_client();\n  let error = c.sign_up(&email, password).await.unwrap_err();\n  assert_eq!(error.code, ErrorCode::InvalidRequest);\n  assert_eq!(\n    error.message,\n    \"Invalid request:Password should be at least 6 characters.\"\n  );\n}\n\n#[tokio::test]\nasync fn sign_up_but_existing_user() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_up(&user.email, &user.password).await.unwrap();\n}\n\n#[tokio::test]\nasync fn sign_up_oauth_not_available() {\n  let c = localhost_client();\n  let err = c\n    .generate_oauth_url_with_provider(&AuthProvider::Zoom)\n    .await\n    .err()\n    .unwrap();\n  assert_eq!(\n    // Change Zoom to any other valid OAuth provider\n    // to manually open the browser and login\n    err.code,\n    ErrorCode::InvalidOAuthProvider\n  );\n}\n\n#[tokio::test]\nasync fn concurrent_user_sign_up_test() {\n  let mut tasks = Vec::new();\n  for _i in 0..30 {\n    let task = tokio::spawn(async move {\n      let _ = TestClient::new_user().await;\n      tokio::time::sleep(Duration::from_millis(300)).await;\n    });\n    tasks.push(task);\n  }\n\n  let results = futures::future::join_all(tasks).await;\n  for result in results {\n    assert!(result.is_ok(), \"Task completed successfully\");\n  }\n}\n"
  },
  {
    "path": "tests/user/update.rs",
    "content": "use app_error::ErrorCode;\nuse client_api::ws::{WSClient, WSClientConfig};\nuse client_api_test::*;\nuse serde_json::json;\nuse shared_entity::dto::auth_dto::{UpdateUserParams, UserMetaData};\nuse std::time::Duration;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn update_but_not_logged_in() {\n  let client = localhost_client();\n  let error = client\n    .update_user(UpdateUserParams::new().with_name(\"new name\"))\n    .await\n    .unwrap_err();\n\n  assert_eq!(error.code, ErrorCode::NotLoggedIn);\n}\n\n#[tokio::test]\nasync fn update_password_same_password() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n  let err = c\n    .update_user(\n      UpdateUserParams::new()\n        .with_password(user.password)\n        .with_email(user.email),\n    )\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::InvalidRequest);\n  assert_eq!(\n    err.message,\n    \"Invalid request:New password should be different from the old password.\"\n  );\n}\n\n#[tokio::test]\nasync fn update_password_and_revert() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  let new_password = \"Hello456!\";\n  {\n    // change password to new_password\n    c.sign_in_password(&user.email, &user.password)\n      .await\n      .unwrap();\n\n    c.update_user(UpdateUserParams::new().with_password(new_password))\n      .await\n      .unwrap();\n  }\n  {\n    // revert password to old_password\n    let c = localhost_client();\n    c.sign_in_password(&user.email, new_password).await.unwrap();\n    c.update_user(UpdateUserParams::new().with_password(user.password))\n      .await\n      .unwrap();\n  }\n}\n\n#[tokio::test]\nasync fn update_user_name() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n  c.update_user(UpdateUserParams::new().with_name(\"lucas\"))\n    .await\n    .unwrap();\n\n  let profile = c.get_profile().await.unwrap();\n  assert_eq!(profile.name.unwrap().as_str(), \"lucas\");\n}\n\n#[tokio::test]\nasync fn update_user_email() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n\n  let new_email = format!(\"{}@appflowy.io\", Uuid::new_v4());\n  c.update_user(UpdateUserParams::new().with_email(new_email.clone()))\n    .await\n    .unwrap();\n\n  let profile = c.get_profile().await.unwrap();\n  assert_eq!(profile.email.unwrap().as_str(), &new_email);\n}\n#[tokio::test]\nasync fn update_user_metadata() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n\n  let mut metadata = UserMetaData::new();\n  metadata.insert(\"str_value\", \"value\");\n  metadata.insert(\"int_value\", 1);\n\n  c.update_user(UpdateUserParams::new().with_metadata(metadata.clone()))\n    .await\n    .unwrap();\n\n  let profile = c.get_profile().await.unwrap();\n  assert_eq!(profile.metadata.unwrap(), json!(metadata));\n}\n\n#[tokio::test]\nasync fn user_metadata_override() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n\n  let mut metadata_1 = UserMetaData::new();\n  metadata_1.insert(\"str_value\", \"value\");\n  metadata_1.insert(\"int_value\", 1);\n  c.update_user(UpdateUserParams::new().with_metadata(metadata_1.clone()))\n    .await\n    .unwrap();\n\n  let mut metadata_2 = UserMetaData::new();\n  metadata_2.insert(\"bool_value\", false);\n  c.update_user(UpdateUserParams::new().with_metadata(metadata_2))\n    .await\n    .unwrap();\n  metadata_1.insert(\"bool_value\", false);\n\n  let profile = c.get_profile().await.unwrap();\n  assert_eq!(profile.metadata.unwrap(), json!(metadata_1));\n}\n\n#[tokio::test]\nasync fn user_empty_metadata_override() {\n  let (c, user) = generate_unique_registered_user_client().await;\n  c.sign_in_password(&user.email, &user.password)\n    .await\n    .unwrap();\n\n  let mut metadata_1 = UserMetaData::new();\n  metadata_1.insert(\"str_value\", \"value\");\n  metadata_1.insert(\"int_value\", 1);\n  c.update_user(UpdateUserParams::new().with_metadata(metadata_1.clone()))\n    .await\n    .unwrap();\n\n  c.update_user(UpdateUserParams::new().with_metadata(UserMetaData::new()))\n    .await\n    .unwrap();\n\n  let profile = c.get_profile().await.unwrap();\n  assert_eq!(profile.metadata.unwrap(), json!(metadata_1));\n}\n\n#[tokio::test]\nasync fn user_change_notify_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let ws_client = WSClient::new(WSClientConfig::default(), c.clone(), c.clone());\n  let mut user_change_recv = ws_client.subscribe_user_changed();\n  ws_client.connect().await.unwrap();\n\n  // After update user, the user_change_recv should receive a user change message via the websocket\n  let fut = Box::pin(async move {\n    c.update_user(UpdateUserParams::new().with_name(\"lucas\"))\n      .await\n      .unwrap();\n    let profile = c.get_profile().await.unwrap();\n    assert_eq!(profile.name.unwrap().as_str(), \"lucas\");\n    tokio::time::sleep(Duration::from_secs(5)).await;\n  });\n\n  tokio::select! {\n    result = tokio::time::timeout(Duration::from_secs(5), async {\n      println!(\"user_change: {:?}\", user_change_recv.recv().await.unwrap());\n    }) => {\n      result.unwrap();\n    },\n    _ = fut => {\n      panic!(\"update user timeout\");\n    },\n  }\n}\n\n#[cfg(feature = \"sync-v2\")]\n#[tokio::test]\nasync fn user_change_notify_test_v2() {\n  use appflowy_proto::WorkspaceNotification;\n  let test_client = TestClient::new_user().await;\n  let workspace_id = test_client.workspace_id().await;\n  let mut workspace_changed = test_client.subscribe_workspace_notification(&workspace_id);\n\n  // Update user name\n  let new_name = \"lucas\";\n  let user_id = test_client.uid().await;\n  let api_client = test_client.api_client.clone();\n  let clone_api_client = api_client.clone();\n  tokio::spawn(async move {\n    tokio::time::sleep(Duration::from_secs(3)).await;\n    clone_api_client\n      .update_user(UpdateUserParams::new().with_name(new_name))\n      .await\n      .unwrap();\n  });\n\n  // Wait for notification with a reasonable timeout\n  match tokio::time::timeout(Duration::from_secs(30), workspace_changed.recv()).await {\n    Ok(notification) => {\n      if let Ok(WorkspaceNotification::UserProfileChange { uid, .. }) = notification {\n        assert_eq!(user_id, uid);\n        let profile = api_client.get_profile().await.unwrap();\n        assert_eq!(profile.name.unwrap().as_str(), new_name);\n      }\n    },\n    Err(_) => {\n      panic!(\"Timed out waiting for user change notification\");\n    },\n  }\n}\n"
  },
  {
    "path": "tests/user/user_awareness_test.rs",
    "content": "use client_api_test::TestClient;\n\n#[tokio::test]\nasync fn edit_workspace_without_permission() {\n  let client = TestClient::new_user().await;\n  let user_awareness = client.get_user_awareness().await;\n  println!(\"user_awareness: {:?}\", user_awareness.to_json());\n}\n"
  },
  {
    "path": "tests/websocket/actor_test.rs",
    "content": "use actix::{Actor, Context, Handler};\nuse appflowy_collaborate::actix_ws::client::rt_client::{\n  HandlerResult, RealtimeClient, RealtimeServer,\n};\nuse appflowy_collaborate::actix_ws::entities::{ClientWebSocketMessage, Connect, Disconnect};\nuse appflowy_collaborate::error::RealtimeError;\nuse collab_rt_entity::user::RealtimeUser;\nuse collab_rt_entity::{MessageByObjectId, RealtimeMessage};\nuse semver::Version;\nuse std::time::Duration;\n\n#[actix_rt::test]\nasync fn test_handle_message() {\n  let device_id = \"device_id\".to_string();\n  let session_id = \"session_id\".to_string();\n  let user = RealtimeUser::new(1, device_id, session_id, 2, \"0.5.8\".to_string());\n  let server = MockRealtimeServer::new(10).start();\n  let client_version = Version::new(0, 5, 0);\n\n  let (_tx, external_source) = tokio::sync::mpsc::channel(100);\n  let client = RealtimeClient::new(\n    user,\n    server,\n    Duration::from_secs(6),\n    Duration::from_secs(10),\n    client_version,\n    external_source,\n    10,\n  );\n\n  let message = RealtimeMessage::ClientCollabV2(MessageByObjectId::new_with_message(\n    \"object_id\".to_string(),\n    vec![],\n  ));\n  client.try_send(message).unwrap();\n}\n\n#[actix_rt::test]\n#[should_panic]\nasync fn server_mailbox_full_test() {\n  let device_id = \"device_id\".to_string();\n  let session_id = \"session_id\".to_string();\n  let user = RealtimeUser::new(1, device_id, session_id, 2, \"0.5.8\".to_string());\n  let server = MockRealtimeServer::new(5).start();\n  let client_version = Version::new(0, 5, 0);\n\n  let mut handles = vec![];\n  // simulate 5 clients sending messages to the server\n  // the mailbox size of the server is 5, so the server will be overwhelmed. When client want to send\n  // more message to the server, the server will return mailbox full error.\n  for _ in 0..5 {\n    let cloned_user = user.clone();\n    let cloned_server = server.clone();\n    let cloned_client_version = client_version.clone();\n    let handle = tokio::spawn(async move {\n      let (_tx, external_source) = tokio::sync::mpsc::channel(100);\n      let client = RealtimeClient::new(\n        cloned_user,\n        cloned_server,\n        Duration::from_secs(6),\n        Duration::from_secs(10),\n        cloned_client_version,\n        external_source,\n        10,\n      );\n      for _ in 0..10 {\n        let message = RealtimeMessage::ClientCollabV2(MessageByObjectId::new_with_message(\n          \"object_id\".to_string(),\n          vec![],\n        ));\n        client.try_send(message).unwrap();\n      }\n    });\n    handles.push(handle);\n  }\n  let results = futures::future::join_all(handles).await;\n  for result in results {\n    assert!(result.is_ok(), \"{:?}\", result.unwrap());\n  }\n}\n\n#[actix_rt::test]\nasync fn client_rate_limit_hit_test() {\n  let device_id = \"device_id\".to_string();\n  let session_id = \"session_id\".to_string();\n  let user = RealtimeUser::new(1, device_id, session_id, 2, \"0.5.8\".to_string());\n  let server = MockRealtimeServer::new(5).start();\n  let client_version = Version::new(0, 5, 0);\n\n  let mut handles = vec![];\n  // We are setting up a simulation where five clients attempt to send messages to a server simultaneously.\n  // The server has been configured to handle a maximum of five messages at any given time, which represents\n  // its mailbox capacity. This limitation means the server can become overwhelmed if it receives too many\n  // messages in a short period.\n  // However, to manage this potential overflow, each client incorporates a rate-limiting mechanism\n  // set to allow only one message per client. This rate-limiting effectively prevents the scenario where\n  // all clients try to send messages beyond the server's capacity simultaneously.\n  for _ in 0..5 {\n    let cloned_user = user.clone();\n    let cloned_server = server.clone();\n    let cloned_client_version = client_version.clone();\n    let handle = tokio::spawn(async move {\n      let (_tx, external_source) = tokio::sync::mpsc::channel(100);\n      let client = RealtimeClient::new(\n        cloned_user,\n        cloned_server,\n        Duration::from_secs(6),\n        Duration::from_secs(10),\n        cloned_client_version,\n        external_source,\n        1,\n      );\n      for _ in 0..10 {\n        let message = RealtimeMessage::ClientCollabV2(MessageByObjectId::new_with_message(\n          \"object_id\".to_string(),\n          vec![],\n        ));\n        if let Err(err) = client.try_send(message) {\n          if err.is_too_many_message() {\n            continue;\n          } else {\n            panic!(\"{:?}\", err);\n          }\n        }\n      }\n    });\n    handles.push(handle);\n  }\n  let results = futures::future::join_all(handles).await;\n  for result in results {\n    assert!(result.is_ok(), \"{:?}\", result.unwrap());\n  }\n}\n\nstruct MockRealtimeServer {\n  mailbox_size: usize,\n}\n\nimpl MockRealtimeServer {\n  fn new(mailbox_size: usize) -> Self {\n    Self { mailbox_size }\n  }\n}\n\nimpl Actor for MockRealtimeServer {\n  type Context = Context<Self>;\n\n  fn started(&mut self, ctx: &mut Self::Context) {\n    ctx.set_mailbox_capacity(self.mailbox_size);\n  }\n}\n\nimpl Handler<ClientWebSocketMessage> for MockRealtimeServer {\n  type Result = HandlerResult;\n\n  fn handle(&mut self, _msg: ClientWebSocketMessage, _ctx: &mut Self::Context) -> Self::Result {\n    Ok(())\n  }\n}\n\nimpl Handler<Connect> for MockRealtimeServer {\n  type Result = anyhow::Result<(), RealtimeError>;\n\n  fn handle(&mut self, _msg: Connect, _ctx: &mut Self::Context) -> Self::Result {\n    Ok(())\n  }\n}\n\nimpl Handler<Disconnect> for MockRealtimeServer {\n  type Result = anyhow::Result<(), RealtimeError>;\n\n  fn handle(&mut self, _msg: Disconnect, _ctx: &mut Self::Context) -> Self::Result {\n    Ok(())\n  }\n}\n\nimpl RealtimeServer for MockRealtimeServer {}\n"
  },
  {
    "path": "tests/websocket/conn_test.rs",
    "content": "use std::time::{Duration, SystemTime};\nuse tokio::time::timeout;\n\nuse client_api::ws::{ConnectState, WSClient, WSClientConfig};\nuse client_api_test::generate_unique_registered_user_client;\n\n#[tokio::test]\nasync fn realtime_connect_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let ws_client = WSClient::new(WSClientConfig::default(), c.clone(), c.clone());\n  let mut state = ws_client.subscribe_connect_state();\n  tokio::spawn(async move { ws_client.connect().await });\n  let connect_future = async {\n    loop {\n      match state.recv().await {\n        Ok(ConnectState::Connected) => {\n          break;\n        },\n        Ok(_) => {},\n        Err(err) => panic!(\"Receiver Error: {:?}\", err),\n      }\n    }\n  };\n\n  // Apply the timeout\n  match timeout(Duration::from_secs(10), connect_future).await {\n    Ok(_) => {},\n    Err(_) => panic!(\"Connection timeout.\"),\n  }\n}\n\n#[tokio::test]\nasync fn realtime_connect_after_token_exp_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n\n  // Set the token to be expired\n  c.token().write().as_mut().unwrap().expires_at = SystemTime::now()\n    .duration_since(SystemTime::UNIX_EPOCH)\n    .unwrap()\n    .as_secs() as i64;\n\n  let ws_client = WSClient::new(WSClientConfig::default(), c.clone(), c.clone());\n  let mut state = ws_client.subscribe_connect_state();\n  tokio::spawn(async move { ws_client.connect().await });\n  let connect_future = async {\n    loop {\n      match state.recv().await {\n        Ok(ConnectState::Connected) => {\n          break;\n        },\n        Ok(_) => {},\n        Err(err) => panic!(\"Receiver Error: {:?}\", err),\n      }\n    }\n  };\n\n  // Apply the timeout\n  match timeout(Duration::from_secs(10), connect_future).await {\n    Ok(_) => {},\n    Err(_) => panic!(\"Connection timeout.\"),\n  }\n}\n\n#[tokio::test]\nasync fn realtime_disconnect_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let ws_client = WSClient::new(WSClientConfig::default(), c.clone(), c.clone());\n  ws_client.connect().await.unwrap();\n\n  let mut state = ws_client.subscribe_connect_state();\n  loop {\n    tokio::select! {\n        _ = ws_client.disconnect() => {},\n       value = state.recv() => {\n        let new_state = value.unwrap();\n        if new_state == ConnectState::Lost {\n          break;\n        }\n      },\n    }\n  }\n}\n"
  },
  {
    "path": "tests/websocket/mod.rs",
    "content": "mod actor_test;\nmod conn_test;\n"
  },
  {
    "path": "tests/workspace/access_request.rs",
    "content": "use app_error::ErrorCode;\nuse client_api::entity::CreateAccessRequestParams;\nuse client_api_test::generate_unique_registered_user_client;\nuse shared_entity::dto::workspace_dto::ViewLayout;\n\n#[tokio::test]\nasync fn access_request_test() {\n  let (owner_client, _) = generate_unique_registered_user_client().await;\n  let workspaces = owner_client.get_workspaces().await.unwrap();\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = owner_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let view_id = folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap()\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .unwrap()\n    .view_id;\n  let data = CreateAccessRequestParams {\n    workspace_id,\n    view_id,\n  };\n  let (requester_client, requester) = generate_unique_registered_user_client().await;\n  let access_request = requester_client\n    .create_access_request(data.clone())\n    .await\n    .unwrap();\n  let resp = requester_client.create_access_request(data).await;\n  assert!(resp.is_err());\n  assert_eq!(\n    resp.unwrap_err().code,\n    ErrorCode::AccessRequestAlreadyExists\n  );\n  // Only workspace owner should be allowed to view access requests\n  let resp = requester_client\n    .get_access_request(access_request.request_id)\n    .await;\n  assert!(resp.is_err());\n  assert_eq!(resp.unwrap_err().code, ErrorCode::NotEnoughPermissions);\n\n  let access_request_id = access_request.request_id;\n  let access_request_to_be_approved = owner_client\n    .get_access_request(access_request_id)\n    .await\n    .unwrap();\n  assert_eq!(\n    access_request_to_be_approved.requester.email,\n    requester.email\n  );\n  assert_eq!(\n    access_request_to_be_approved.view.view_id,\n    view_id.to_string()\n  );\n  assert_eq!(access_request_to_be_approved.view.layout, ViewLayout::Board);\n  assert_eq!(\n    access_request_to_be_approved.workspace.workspace_id,\n    workspace_id\n  );\n  assert_eq!(\n    access_request_to_be_approved.workspace.member_count,\n    Some(1)\n  );\n  owner_client\n    .approve_access_request(access_request_id)\n    .await\n    .unwrap();\n  let workspace_members = owner_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert!(workspace_members.iter().any(|m| m.email == requester.email));\n}\n"
  },
  {
    "path": "tests/workspace/asset/read_me.json",
    "content": "{\n  \"type\": \"page\",\n  \"data\": {\n    \"delta\": [\n      {\"insert\": \"\"}\n    ]\n  },\n  \"children\": [\n    {\n      \"type\": \"heading\",\n      \"data\": { \"delta\": [{ \"insert\": \"Welcome to AppFlowy!\" }], \"level\": 1 }\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"delta\": [{ \"insert\": \"Here are the basics\" }], \"level\": 2 }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [{ \"insert\": \"Click anywhere and just start typing.\" }],\n        \"checked\": false\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          {\n            \"attributes\": { \"bg_color\": \"0x4dffeb3b\" },\n            \"insert\": \"Highlight \"\n          },\n          { \"insert\": \"any text, and use the editing menu to \" },\n          { \"attributes\": { \"italic\": true }, \"insert\": \"style\" },\n          { \"insert\": \" \" },\n          { \"attributes\": { \"bold\": true }, \"insert\": \"your\" },\n          { \"insert\": \" \" },\n          { \"attributes\": { \"underline\": true }, \"insert\": \"writing\" },\n          { \"insert\": \" \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"however\" },\n          { \"insert\": \" you \" },\n          { \"attributes\": { \"strikethrough\": true }, \"insert\": \"like.\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"As soon as you type \" },\n          {\n            \"attributes\": { \"code\": true, \"font_color\": \"0xff00b5ff\" },\n            \"insert\": \"/\"\n          },\n          { \"insert\": \" a menu will pop up. Select \" },\n          {\n            \"attributes\": { \"bg_color\": \"0x4d9c27b0\" },\n            \"insert\": \"different types\"\n          },\n          { \"insert\": \" of content blocks you can add.\" }\n        ]\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Type \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"/\" },\n          { \"insert\": \" followed by \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"/bullet\" },\n          { \"insert\": \" or \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"/num\" },\n          { \"attributes\": { \"code\": false }, \"insert\": \" to create a list.\" }\n        ],\n        \"checked\": false\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"+ New Page \" },\n          {\n            \"insert\": \"button at the bottom of your sidebar to add a new page.\"\n          }\n        ],\n        \"checked\": true\n      }\n    },\n    {\n      \"type\": \"todo_list\",\n      \"data\": {\n        \"checked\": false,\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"+\" },\n          { \"insert\": \" next to any page title in the sidebar to \" },\n          {\n            \"attributes\": { \"font_color\": \"0xff8427e0\" },\n            \"insert\": \"quickly\"\n          },\n          { \"insert\": \" add a new subpage, \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"Document\" },\n          { \"attributes\": { \"code\": false }, \"insert\": \", \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"Grid\" },\n          { \"attributes\": { \"code\": false }, \"insert\": \", or \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"Kanban Board\" },\n          { \"attributes\": { \"code\": false }, \"insert\": \".\" }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"divider\" },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"delta\": [{ \"insert\": \"Keyboard shortcuts, markdown, and code block\" }],\n        \"level\": 2\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Keyboard shortcuts \" },\n          {\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/shortcuts\"\n            },\n            \"insert\": \"guide\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Markdown \" },\n          {\n            \"attributes\": {\n              \"href\": \"https://appflowy.gitbook.io/docs/essential-documentation/markdown\"\n            },\n            \"insert\": \"reference\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"numbered_list\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Type \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"/code\" },\n          {\n            \"attributes\": { \"code\": false },\n            \"insert\": \" to insert a code block\"\n          }\n        ]\n      }\n    },\n    {\n      \"type\": \"code\",\n      \"data\": {\n        \"language\": \"rust\",\n        \"delta\": [\n          {\n            \"insert\": \"// This is the main function.\\nfn main() {\\n    // Print text to the console.\\n    println!(\\\"Hello World!\\\");\\n}\"\n          }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"heading\",\n      \"data\": { \"level\": 2, \"delta\": [{ \"insert\": \"Have a question❓\" }] }\n    },\n    {\n      \"type\": \"quote\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"Click \" },\n          { \"attributes\": { \"code\": true }, \"insert\": \"?\" },\n          { \"insert\": \" at the bottom right for help and support.\" }\n        ]\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    {\n      \"type\": \"callout\",\n      \"data\": {\n        \"delta\": [\n          { \"insert\": \"\\nLike AppFlowy? Follow us:\\n\" },\n          {\n            \"attributes\": {\n              \"href\": \"https://github.com/AppFlowy-IO/AppFlowy\"\n            },\n            \"insert\": \"GitHub\"\n          },\n          { \"insert\": \"\\n\" },\n          {\n            \"attributes\": { \"href\": \"https://twitter.com/appflowy\" },\n            \"insert\": \"Twitter\"\n          },\n          { \"insert\": \": @appflowy\\n\" },\n          {\n            \"attributes\": { \"href\": \"https://blog-appflowy.ghost.io/\" },\n            \"insert\": \"Newsletter\"\n          },\n          { \"insert\": \"\\n\" }\n        ],\n        \"icon\": \"🥰\"\n      }\n    },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } },\n    { \"type\": \"paragraph\", \"data\": { \"delta\": [] } }\n  ]\n}\n"
  },
  {
    "path": "tests/workspace/default_user_workspace.rs",
    "content": "use client_api_test::*;\nuse collab::core::collab::default_client_id;\nuse collab::core::origin::CollabOrigin;\nuse collab_document::{blocks::json_str_to_hashmap, document::Document};\nuse collab_entity::CollabType;\nuse collab_folder::{IconType, ViewIcon, ViewLayout};\nuse uuid::Uuid;\n\n/// Get the document collab from the remote server\nasync fn get_document_collab_from_remote(\n  test_client: &mut TestClient,\n  workspace_id: Uuid,\n  document_id: Uuid,\n) -> Document {\n  let resp = test_client\n    .get_collab(workspace_id, document_id, CollabType::Document)\n    .await\n    .unwrap();\n  Document::open_with_options(\n    CollabOrigin::Empty,\n    resp.encode_collab.into(),\n    &document_id.to_string(),\n    default_client_id(),\n  )\n  .unwrap()\n}\n\n// |-- General (space)\n//     |-- Getting started (document)\n//          |-- Desktop guide (document)\n//          |-- Mobile guide (document)\n//          |-- Web guide (document)\n//     |-- To-dos (board)\n// |-- Shared (space)\n//     |-- ... (empty)\n#[tokio::test]\nasync fn get_user_default_workspace_test() {\n  let email = generate_unique_email();\n  let password = \"Hello!123#\";\n  let c = localhost_client();\n  c.sign_up(&email, password).await.unwrap();\n  let mut test_client = TestClient::new_user().await;\n  let folder = test_client.get_user_folder().await;\n\n  let uid = test_client.uid().await;\n  let workspace_id = test_client.workspace_id().await;\n  let views = folder.get_views_belong_to(&workspace_id.to_string(), test_client.uid().await);\n\n  // 2 spaces\n  assert_eq!(views.len(), 2);\n\n  // the first view is the general space\n  let general_space = views[0].clone();\n  assert_eq!(general_space.name, \"General\");\n  assert!(general_space.icon.is_none());\n  assert!(general_space.extra.is_some());\n  let extra = general_space.extra.as_ref().unwrap();\n  let general_space_extra = json_str_to_hashmap(extra).unwrap();\n  assert_eq!(\n    general_space_extra.get(\"is_space\"),\n    Some(&serde_json::json!(true))\n  );\n\n  // it contains 1 document and 1 board\n  let general_space_views = folder.get_views_belong_to(&general_space.id, test_client.uid().await);\n  assert_eq!(general_space_views.len(), 2);\n  {\n    // the first view is the getting started document, and contains 2 sub views\n    let getting_started_view = general_space_views[0].clone();\n    assert_eq!(getting_started_view.name, \"Getting started\");\n    assert_eq!(getting_started_view.layout, ViewLayout::Document);\n    assert_eq!(\n      getting_started_view.icon,\n      Some(ViewIcon {\n        ty: IconType::Emoji,\n        value: \"🌟\".to_string()\n      })\n    );\n\n    let getting_started_document = get_document_collab_from_remote(\n      &mut test_client,\n      workspace_id,\n      getting_started_view.id.parse().unwrap(),\n    )\n    .await;\n    let document_data = getting_started_document.get_document_data().unwrap();\n    assert_eq!(document_data.blocks.len(), 16);\n\n    let getting_started_sub_views =\n      folder.get_views_belong_to(&getting_started_view.id, test_client.uid().await);\n    assert_eq!(getting_started_sub_views.len(), 3);\n\n    let desktop_guide_view = getting_started_sub_views[0].clone();\n    assert_eq!(desktop_guide_view.name, \"Desktop guide\");\n    assert_eq!(desktop_guide_view.layout, ViewLayout::Document);\n    assert_eq!(\n      desktop_guide_view.icon,\n      Some(ViewIcon {\n        ty: IconType::Emoji,\n        value: \"📎\".to_string()\n      })\n    );\n    let desktop_guide_document = get_document_collab_from_remote(\n      &mut test_client,\n      workspace_id,\n      desktop_guide_view.id.parse().unwrap(),\n    )\n    .await;\n    let desktop_guide_document_data = desktop_guide_document.get_document_data().unwrap();\n    assert_eq!(desktop_guide_document_data.blocks.len(), 39);\n\n    let mobile_guide_view = getting_started_sub_views[1].clone();\n    assert_eq!(mobile_guide_view.name, \"Mobile guide\");\n    assert_eq!(mobile_guide_view.layout, ViewLayout::Document);\n    assert_eq!(mobile_guide_view.icon, None);\n    let mobile_guide_document = get_document_collab_from_remote(\n      &mut test_client,\n      workspace_id,\n      mobile_guide_view.id.parse().unwrap(),\n    )\n    .await;\n    let mobile_guide_document_data = mobile_guide_document.get_document_data().unwrap();\n    assert_eq!(mobile_guide_document_data.blocks.len(), 33);\n\n    let web_guide_view = getting_started_sub_views[2].clone();\n    assert_eq!(web_guide_view.name, \"Web guide\");\n    assert_eq!(web_guide_view.layout, ViewLayout::Document);\n    assert_eq!(web_guide_view.icon, None);\n    let web_guide_document = get_document_collab_from_remote(\n      &mut test_client,\n      workspace_id,\n      web_guide_view.id.parse().unwrap(),\n    )\n    .await;\n    let web_guide_document_data = web_guide_document.get_document_data().unwrap();\n    assert_eq!(web_guide_document_data.blocks.len(), 31);\n  }\n\n  // the second view is the to-dos board, and contains 0 sub views\n  {\n    let to_dos_view = general_space_views[1].clone();\n    assert_eq!(to_dos_view.name, \"To-dos\");\n    assert_eq!(to_dos_view.layout, ViewLayout::Board);\n    assert_eq!(\n      to_dos_view.icon,\n      Some(ViewIcon {\n        ty: IconType::Emoji,\n        value: \"✅\".to_string()\n      })\n    );\n\n    let to_dos_sub_views = folder.get_views_belong_to(&to_dos_view.id, test_client.uid().await);\n    assert_eq!(to_dos_sub_views.len(), 0);\n  }\n\n  // shared space is empty\n  let shared_space = views[1].clone();\n  assert_eq!(shared_space.name, \"Shared\");\n  assert!(shared_space.icon.is_none());\n  assert!(shared_space.extra.is_some());\n  let extra = shared_space.extra.as_ref().unwrap();\n  let shared_space_extra = json_str_to_hashmap(extra).unwrap();\n  assert_eq!(\n    shared_space_extra.get(\"is_space\"),\n    Some(&serde_json::json!(true))\n  );\n  let shared_space_views = folder.get_views_belong_to(&shared_space.id, uid);\n  assert_eq!(shared_space_views.len(), 0);\n}\n"
  },
  {
    "path": "tests/workspace/edit_workspace.rs",
    "content": "use std::time::Duration;\n\nuse collab_entity::CollabType;\nuse serde_json::json;\nuse tokio::time::sleep;\n\nuse client_api_test::*;\nuse database_entity::dto::AFRole;\n\n#[tokio::test]\nasync fn init_sync_workspace_with_member_permission() {\n  let mut owner = TestClient::new_user().await;\n  let mut guest = TestClient::new_user().await;\n  let workspace_id = owner.workspace_id().await;\n  owner.open_workspace_collab(workspace_id).await;\n\n  // TODO(nathan): write test for AFRole::Guest\n  // add client 2 as the member of the workspace then the client 2 will receive the update.\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Member)\n    .await\n    .unwrap();\n  guest.open_workspace_collab(workspace_id).await;\n\n  owner.insert_into(&workspace_id, \"name\", \"AppFlowy\").await;\n  owner\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n\n  assert_client_collab_within_secs(\n    &mut owner,\n    &workspace_id,\n    \"name\",\n    json!({\"name\": \"AppFlowy\"}),\n    60,\n  )\n  .await;\n  assert_client_collab_within_secs(\n    &mut guest,\n    &workspace_id,\n    \"name\",\n    json!({\"name\": \"AppFlowy\"}),\n    60,\n  )\n  .await;\n}\n\n#[tokio::test]\nasync fn edit_workspace_with_guest_permission() {\n  let mut owner = TestClient::new_user().await;\n  let mut guest = TestClient::new_user().await;\n  let workspace_id = owner.workspace_id().await;\n  owner.open_workspace_collab(workspace_id).await;\n\n  // add client 2 as the member of the workspace then the client 2 can receive the update.\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Guest)\n    .await\n    .unwrap();\n\n  owner\n    .insert_into(&workspace_id, \"name\", \"workspace 1\")\n    .await;\n  owner\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n\n  guest.open_workspace_collab(workspace_id).await;\n  // make sure the client 2 has received the remote updates before the client 2 edits the collab\n  sleep(Duration::from_secs(3)).await;\n\n  // client_2 only has the guest permission, so it can not edit the collab\n  guest\n    .insert_into(&workspace_id, \"name\", \"workspace 2\")\n    .await;\n\n  let expected_value = json!({\"name\": \"workspace 2\"});\n\n  assert_client_collab_include_value(&mut owner, &workspace_id, expected_value.clone())\n    .await\n    .unwrap();\n  assert_client_collab_include_value(&mut guest, &workspace_id, expected_value.clone())\n    .await\n    .unwrap();\n\n  assert_server_collab(\n    workspace_id,\n    &mut owner.api_client,\n    workspace_id,\n    &CollabType::Folder,\n    30,\n    expected_value,\n  )\n  .await\n  .unwrap();\n}\n"
  },
  {
    "path": "tests/workspace/import_test.rs",
    "content": "use anyhow::Error;\nuse client_api_test::TestClient;\nuse collab_document::importer::define::URL_FIELD;\nuse collab_folder::ViewLayout;\n\nuse collab_database::database::get_inline_view_id;\nuse collab_document::blocks::BlockType;\nuse std::path::PathBuf;\nuse std::time::Duration;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn import_blog_post_test() {\n  // Step 1: Import the blog post zip\n  let (client, imported_workspace_id) = import_notion_zip_until_complete(\"blog_post.zip\").await;\n  let uid = client.uid().await;\n\n  // Step 2: Fetch the folder and views\n  let folder = client.get_folder(imported_workspace_id).await;\n  let mut space_views = folder.get_views_belong_to(&imported_workspace_id.to_string(), uid);\n  assert_eq!(\n    space_views.len(),\n    1,\n    \"Expected 1 view, found {:?}\",\n    space_views\n  );\n\n  // Step 3: Validate the space view name\n  let space_view = space_views.pop().unwrap();\n  assert_eq!(space_view.name, \"Imported Space\");\n\n  // Step 4: Fetch the imported view and document\n  let imported_view = folder\n    .get_views_belong_to(&space_view.id, uid)\n    .pop()\n    .unwrap();\n  let document = client\n    .get_document(imported_workspace_id, imported_view.id.parse().unwrap())\n    .await;\n\n  // Step 5: Generate the expected blob URLs\n  let host = client.api_client.base_url.clone();\n  let object_id = imported_view.id.clone();\n  let blob_names = vec![\n    \"PGTRCFsf2duc7iP3KjE62Xs8LE7B96a0aQtLtGtfIcw=.jpg\",\n    \"fFWPgqwdqbaxPe7Q_vUO143Sa2FypnRcWVibuZYdkRI=.jpg\",\n    \"EIj9Z3yj8Gw8UW60U8CLXx7ulckEs5Eu84LCFddCXII=.jpg\",\n  ];\n\n  let mut expected_urls = blob_names\n    .iter()\n    .map(|s| format!(\"{host}/api/file_storage/{imported_workspace_id}/v1/blob/{object_id}/{s}\"))\n    .collect::<Vec<String>>();\n\n  // Step 6: Concurrently fetch blobs\n  let fetch_blob_futures = blob_names.into_iter().map(|blob_name| {\n    client\n      .api_client\n      .get_blob_v1(&imported_workspace_id, &object_id, blob_name)\n  });\n\n  let blob_results = futures::future::join_all(fetch_blob_futures).await;\n\n  // Ensure all blobs are fetched successfully\n  for result in blob_results {\n    result.unwrap();\n  }\n\n  // Step 7: Extract block URLs from the document and filter expected URLs\n  let page_block_id = document.get_page_id().unwrap();\n  let block_ids = document.get_block_children_ids(&page_block_id);\n\n  for block_id in block_ids.iter() {\n    if let Some((block_type, block_data)) = document.get_block_data(block_id) {\n      if matches!(block_type, BlockType::Image) {\n        let url = block_data.get(URL_FIELD).unwrap().as_str().unwrap();\n        expected_urls.retain(|allowed_url| !url.contains(allowed_url));\n      }\n    }\n  }\n\n  // Step 8: Ensure no expected URLs remain\n  assert!(\n    expected_urls.is_empty(),\n    \"expected URLs to be empty: {:?}\",\n    expected_urls\n  );\n}\n\n#[tokio::test]\nasync fn import_project_and_task_zip_test() {\n  let (client, imported_workspace_id) = import_notion_zip_until_complete(\"project&task.zip\").await;\n  let uid = client.uid().await;\n  let folder = client.get_folder(imported_workspace_id).await;\n  let workspace_database = client.get_workspace_database(imported_workspace_id).await;\n  let space_views = folder.get_views_belong_to(&imported_workspace_id.to_string(), uid);\n  assert_eq!(\n    space_views.len(),\n    1,\n    \"Expected 1 view, found {:?}\",\n    space_views\n  );\n  assert_eq!(space_views[0].name, \"Imported Space\");\n  assert!(space_views[0].space_info().is_some());\n\n  let mut sub_views = folder.get_views_belong_to(&space_views[0].id, uid);\n  let imported_view = sub_views.pop().unwrap();\n  assert_eq!(imported_view.name, \"Projects & Tasks\");\n  assert_eq!(\n    imported_view.children.len(),\n    2,\n    \"Expected 2 views, found {:?}\",\n    imported_view.children\n  );\n  assert_eq!(imported_view.layout, ViewLayout::Document);\n\n  let sub_views = folder.get_views_belong_to(&imported_view.id, uid);\n  for (index, view) in sub_views.iter().enumerate() {\n    if index == 0 {\n      assert_eq!(view.name, \"Projects\");\n      assert_eq!(view.layout, ViewLayout::Grid);\n\n      let database_id = workspace_database\n        .get_database_meta_with_view_id(&view.id)\n        .unwrap()\n        .database_id\n        .clone();\n      let database = client\n        .get_database(imported_workspace_id, &database_id)\n        .await;\n      let inline_views = get_inline_view_id(&database).unwrap();\n      let fields = database.get_fields_in_view(&inline_views, None);\n      let rows = database.collect_all_rows(false).await;\n      assert_eq!(rows.len(), 4);\n      assert_eq!(fields.len(), 13);\n\n      continue;\n    }\n\n    if index == 1 {\n      assert_eq!(view.name, \"Tasks\");\n      assert_eq!(view.layout, ViewLayout::Grid);\n\n      let database_id = workspace_database\n        .get_database_meta_with_view_id(&view.id)\n        .unwrap()\n        .database_id\n        .clone();\n      let database = client\n        .get_database(imported_workspace_id, &database_id)\n        .await;\n      let inline_views = get_inline_view_id(&database).unwrap();\n      let fields = database.get_fields_in_view(&inline_views, None);\n      let rows = database.collect_all_rows(false).await;\n      assert_eq!(rows.len(), 17);\n      assert_eq!(fields.len(), 13);\n      continue;\n    }\n\n    panic!(\"Unexpected view found: {:?}\", view);\n  }\n}\n\n#[tokio::test]\nasync fn imported_workspace_do_not_become_latest_visit_workspace_test() {\n  let client = TestClient::new_user().await;\n  let file_path = PathBuf::from(\"tests/workspace/asset/blog_post.zip\".to_string());\n  client.api_client.import_file(&file_path).await.unwrap();\n\n  // When importing a Notion file, a new task is spawned to create a workspace for the imported data.\n  // However, the workspace should remain hidden until the import is completed successfully.\n  let user_workspace = client.get_user_workspace_info().await;\n  let visiting_workspace_id = user_workspace.visiting_workspace.workspace_id;\n  assert_eq!(user_workspace.workspaces.len(), 1);\n  assert_eq!(\n    user_workspace.visiting_workspace.workspace_id,\n    user_workspace.workspaces[0].workspace_id\n  );\n\n  wait_until_num_import_task_complete(&client, 1).await;\n\n  // after the workspace was imported, then the workspace should be visible\n  let user_workspace = client.get_user_workspace_info().await;\n  assert_eq!(user_workspace.workspaces.len(), 2);\n  assert_eq!(\n    user_workspace.visiting_workspace.workspace_id,\n    visiting_workspace_id,\n  );\n}\n\n#[allow(dead_code)]\nasync fn upload_file(\n  client: &TestClient,\n  name: &str,\n  upload_after_secs: Option<u64>,\n) -> Result<(), Error> {\n  let file_path = PathBuf::from(format!(\"tests/workspace/asset/{name}\"));\n  let url = client\n    .api_client\n    .create_import(&file_path)\n    .await?\n    .presigned_url;\n\n  if let Some(secs) = upload_after_secs {\n    tokio::time::sleep(Duration::from_secs(secs)).await;\n  }\n\n  client\n    .api_client\n    .upload_import_file(&file_path, &url)\n    .await?;\n  Ok(())\n}\n\n// upload_after_secs: simulate the delay of uploading the file\nasync fn import_notion_zip_until_complete(name: &str) -> (TestClient, Uuid) {\n  let client = TestClient::new_user().await;\n\n  // Uncomment the following lines to use the predicated upload file API.\n  // Currently, we use `upload_file` to send a file to appflowy_worker, which then\n  // processes the upload task.\n  let file_path = PathBuf::from(format!(\"tests/workspace/asset/{name}\"));\n  client.api_client.import_file(&file_path).await.unwrap();\n  // upload_file(&client, name, None).await.unwrap();\n\n  let default_workspace_id = client.workspace_id().await;\n\n  // when importing a file, the workspace for the file should be created and it's\n  // not visible until the import task is completed\n  let workspaces = client.api_client.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let tasks = client.api_client.get_import_list().await.unwrap().tasks;\n  assert_eq!(tasks.len(), 1);\n  assert_eq!(tasks[0].status, 0);\n\n  wait_until_num_import_task_complete(&client, 1).await;\n\n  // after the import task is completed, the new workspace should be visible\n  let workspaces = client.api_client.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 2);\n\n  let imported_workspace = workspaces\n    .into_iter()\n    .find(|workspace| workspace.workspace_id != default_workspace_id)\n    .expect(\"Failed to find imported workspace\");\n\n  let imported_workspace_id = imported_workspace.workspace_id;\n  (client, imported_workspace_id)\n}\n\nasync fn wait_until_num_import_task_complete(client: &TestClient, num: usize) {\n  let mut task_completed = false;\n  let max_retries = 12;\n  let mut retries = 0;\n  while !task_completed {\n    tokio::time::sleep(Duration::from_secs(10)).await;\n    let tasks = client.api_client.get_import_list().await.unwrap().tasks;\n    assert_eq!(tasks.len(), num);\n    if tasks[0].status == 1 {\n      task_completed = true;\n    }\n    retries += 1;\n\n    if retries > max_retries {\n      eprintln!(\"{:?}\", tasks);\n      break;\n    }\n  }\n\n  assert!(\n    task_completed,\n    \"The import task was not completed within the expected time.\"\n  );\n}\n"
  },
  {
    "path": "tests/workspace/invitation_crud.rs",
    "content": "use anyhow::Context;\nuse app_error::ErrorCode;\nuse client_api_test::{generate_unique_registered_user_client, TestClient};\nuse database_entity::dto::{AFRole, AFWorkspaceInvitationStatus};\nuse shared_entity::dto::workspace_dto::{QueryWorkspaceParam, WorkspaceMemberInvitation};\n\n#[tokio::test]\nasync fn invite_workspace_crud() {\n  let (alice_client, alice) = generate_unique_registered_user_client().await;\n  let alice_workspace_id = alice_client\n    .get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id;\n\n  let (bob_client, bob) = generate_unique_registered_user_client().await;\n  let bob_workspace_id = bob_client\n    .get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id;\n\n  // alice invite bob to alice's workspace\n  alice_client\n    .invite_workspace_members(\n      &alice_workspace_id,\n      vec![WorkspaceMemberInvitation {\n        email: bob.email.clone(),\n        role: AFRole::Member,\n        skip_email_send: true,\n        ..Default::default()\n      }],\n    )\n    .await\n    .unwrap();\n\n  // list invitation with no filter\n  let invitations_for_bob = bob_client.list_workspace_invitations(None).await.unwrap();\n  assert_eq!(invitations_for_bob.len(), 1);\n\n  // list invitation with accepted filter\n  let accepted_invs = bob_client\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Accepted))\n    .await\n    .unwrap();\n  assert_eq!(accepted_invs.len(), 0);\n\n  // list invitation with rejected filter\n  let rejected_invs = bob_client\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Rejected))\n    .await\n    .unwrap();\n  assert_eq!(rejected_invs.len(), 0);\n\n  // list invitation with pending filter\n  let pending_invs = bob_client\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Pending))\n    .await\n    .unwrap();\n  assert_eq!(pending_invs.len(), 1);\n  let invite_id = pending_invs.first().unwrap().invite_id.to_string();\n\n  // get invitation by id\n  let invitation = bob_client\n    .get_workspace_invitation(&invite_id)\n    .await\n    .unwrap();\n\n  assert_eq!(invitation.inviter_email, Some(alice.email));\n  assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Pending);\n  assert_eq!(invitation.member_count.unwrap_or(0), 1);\n\n  let (charlie_client, _charlie) = generate_unique_registered_user_client().await;\n  let err = charlie_client\n    .get_workspace_invitation(&invite_id)\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation);\n  let err = charlie_client\n    .accept_workspace_invitation(&invite_id)\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::NotInviteeOfWorkspaceInvitation);\n\n  bob_client\n    .accept_workspace_invitation(&invite_id)\n    .await\n    .unwrap();\n\n  let invitation = bob_client\n    .get_workspace_invitation(&invite_id)\n    .await\n    .unwrap();\n\n  assert_eq!(invitation.status, AFWorkspaceInvitationStatus::Accepted);\n  assert_eq!(invitation.member_count.unwrap_or(0), 2);\n\n  // list invitation with accepted filter\n  let accepted_invs = bob_client\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Accepted))\n    .await\n    .unwrap();\n  assert_eq!(accepted_invs.len(), 1);\n\n  {\n    // alice's view of the workspaces\n    let workspaces = alice_client\n      .get_workspaces_opt(QueryWorkspaceParam {\n        include_member_count: Some(true),\n        include_role: Some(true),\n      })\n      .await\n      .unwrap();\n\n    assert_eq!(workspaces.len(), 1);\n    assert_eq!(workspaces[0].workspace_id, alice_workspace_id);\n    assert_eq!(workspaces[0].member_count, Some(2));\n    assert_eq!(workspaces[0].role, Some(AFRole::Owner));\n  }\n\n  {\n    // bob's view of the workspaces\n    // bob should see 2 workspaces, one is his own and the other is alice's\n    let workspaces = bob_client\n      .get_workspaces_opt(QueryWorkspaceParam {\n        include_member_count: Some(true),\n        include_role: Some(true),\n      })\n      .await\n      .unwrap();\n    assert_eq!(workspaces.len(), 2);\n    {\n      let alice_workspace = workspaces\n        .iter()\n        .find(|w| w.workspace_id == alice_workspace_id)\n        .unwrap();\n      assert_eq!(alice_workspace.member_count, Some(2));\n      assert_eq!(alice_workspace.role, Some(AFRole::Member));\n    }\n    {\n      let bob_workspace = workspaces\n        .iter()\n        .find(|w| w.workspace_id == bob_workspace_id)\n        .unwrap();\n      println!(\"{:?}\", bob_workspace);\n      assert_eq!(bob_workspace.member_count, Some(1));\n      assert_eq!(bob_workspace.role, Some(AFRole::Owner));\n    }\n  }\n}\n\n#[tokio::test]\nasync fn invite_wait_email_sending_success() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let c2 = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = c1.workspace_id().await;\n  let _: () = c1\n    .api_client\n    .invite_workspace_members(\n      &workspace_id,\n      vec![WorkspaceMemberInvitation {\n        email: c2.user.email,\n        role: AFRole::Member,\n        skip_email_send: false,\n        wait_email_send: true,\n      }],\n    )\n    .await\n    .context(\"failed to send email to invite workspace members\")\n    .unwrap();\n}\n"
  },
  {
    "path": "tests/workspace/join_workspace.rs",
    "content": "use client_api::entity::WorkspaceInviteCodeParams;\nuse client_api_test::generate_unique_registered_user_client;\n\n#[tokio::test]\nasync fn join_workspace_by_invite_code() {\n  let (owner_client, _) = generate_unique_registered_user_client().await;\n  let workspaces = owner_client.get_workspaces().await.unwrap();\n  let workspace_id = workspaces[0].workspace_id;\n  let (invitee_client, _) = generate_unique_registered_user_client().await;\n  let invitation_code = owner_client\n    .create_workspace_invitation_code(\n      &workspace_id,\n      &WorkspaceInviteCodeParams {\n        validity_period_hours: None,\n      },\n    )\n    .await\n    .unwrap()\n    .code\n    .unwrap();\n  let retrieved_invite_code = owner_client\n    .get_workspace_invitation_code(&workspace_id)\n    .await\n    .unwrap()\n    .code\n    .unwrap();\n  assert_eq!(invitation_code, retrieved_invite_code);\n  let invitation_code_info = invitee_client\n    .get_invitation_code_info(&invitation_code)\n    .await\n    .unwrap();\n  assert_eq!(invitation_code_info.is_member, Some(false));\n  assert_eq!(invitation_code_info.member_count, 1);\n  assert_eq!(\n    invitation_code_info.workspace_name,\n    workspaces[0].workspace_name\n  );\n  let invited_workspace_id = invitee_client\n    .join_workspace_by_invitation_code(&invitation_code)\n    .await\n    .unwrap()\n    .workspace_id;\n  assert_eq!(workspace_id, invited_workspace_id);\n  assert!(invitee_client\n    .get_workspaces()\n    .await\n    .unwrap()\n    .iter()\n    .any(|w| w.workspace_id == invited_workspace_id));\n  owner_client\n    .delete_workspace_invitation_code(&workspace_id)\n    .await\n    .unwrap();\n  assert!(owner_client\n    .get_workspace_invitation_code(&workspace_id)\n    .await\n    .unwrap()\n    .code\n    .is_none());\n}\n"
  },
  {
    "path": "tests/workspace/member_crud.rs",
    "content": "use app_error::ErrorCode;\nuse client_api::entity::AFWorkspaceInvitationStatus;\nuse client_api_test::{api_client_with_email, TestClient};\nuse database_entity::dto::AFRole;\nuse shared_entity::dto::workspace_dto::WorkspaceMemberInvitation;\n\n#[tokio::test]\nasync fn get_workspace_owner_after_sign_up_test() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n\n  let members = c1\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 1);\n  assert_eq!(members[0].email, c1.email().await);\n}\n\n#[tokio::test]\nasync fn workspace_members_through_invite_or_direct_add() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let member_1 = TestClient::new_user_without_ws_conn().await;\n  let member_2 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = owner.workspace_id().await;\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member_1, AFRole::Member)\n    .await\n    .unwrap();\n\n  // TODO(Zack): fix { code: OAuthError, message: \"code: 500, msg:Error sending magic link, error_id: Some(\\\"3ec69543-e7b9-496d-92d8-f0b73ff09e0f\\\")\" }\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 3);\n}\n\n#[tokio::test]\nasync fn add_workspace_members_not_enough_permission() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let member_1 = TestClient::new_user_without_ws_conn().await;\n  let member_2 = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = owner.workspace_id().await;\n\n  // add client 2 to client 1's workspace\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member_1, AFRole::Member)\n    .await\n    .unwrap();\n\n  // client 2 add client 3 to client 1's workspace but permission denied\n  let error = member_1\n    .invite_and_accepted_workspace_member(&workspace_id, &member_2, AFRole::Member)\n    .await\n    .unwrap_err();\n  assert_eq!(error.code, ErrorCode::NotEnoughPermissions);\n}\n\n#[tokio::test]\nasync fn add_duplicate_workspace_members() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let c2 = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = c1.workspace_id().await;\n\n  c1.invite_and_accepted_workspace_member(&workspace_id, &c2, AFRole::Member)\n    .await\n    .unwrap();\n\n  // next invite should return error since the user is already in the workspace\n  let err = c1\n    .api_client\n    .invite_workspace_members(\n      &workspace_id,\n      vec![WorkspaceMemberInvitation {\n        email: c2.email().await,\n        role: AFRole::Member,\n        skip_email_send: true,\n        ..Default::default()\n      }],\n    )\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::InvalidRequest, \"{:?}\", err);\n\n  // should not find any invitation\n  let invitations = c2\n    .api_client\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Pending))\n    .await\n    .unwrap();\n\n  let is_none = !invitations\n    .iter()\n    .any(|inv| inv.workspace_id == workspace_id);\n  assert!(is_none);\n}\n\n#[tokio::test]\nasync fn add_not_exist_workspace_members() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n  let email = format!(\"{}@appflowy.io\", uuid::Uuid::new_v4());\n  c1.api_client\n    .invite_workspace_members(\n      &workspace_id,\n      vec![WorkspaceMemberInvitation {\n        email: email.clone(),\n        role: AFRole::Member,\n        skip_email_send: true,\n        ..Default::default()\n      }],\n    )\n    .await\n    .unwrap();\n\n  let invited_client = api_client_with_email(&email).await;\n  let invite_id = invited_client\n    .list_workspace_invitations(None)\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .invite_id;\n  invited_client\n    .accept_workspace_invitation(invite_id.to_string().as_str())\n    .await\n    .unwrap();\n\n  let workspaces = invited_client.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 2);\n}\n\n#[tokio::test]\nasync fn update_workspace_member_role_not_enough_permission() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let c2 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n\n  // add client 2 to client 1's workspace\n  c1.invite_and_accepted_workspace_member(&workspace_id, &c2, AFRole::Member)\n    .await\n    .unwrap();\n\n  // client 2 want to update client 2's role to owner\n  let error = c2\n    .try_update_workspace_member(&workspace_id, &c2, AFRole::Owner)\n    .await\n    .unwrap_err();\n  assert_eq!(error.code, ErrorCode::NotEnoughPermissions);\n}\n\n#[tokio::test]\nasync fn update_workspace_member_role_from_guest_to_member() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let guest = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = owner.workspace_id().await;\n\n  // add client 2 to client 1's workspace\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Guest)\n    .await\n    .unwrap();\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 1);\n  assert_eq!(members[0].email, owner.email().await);\n  assert_eq!(members[0].role, AFRole::Owner);\n\n  owner\n    .try_update_workspace_member(&workspace_id, &guest, AFRole::Member)\n    .await\n    .unwrap();\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members[0].email, owner.email().await);\n  assert_eq!(members[0].role, AFRole::Owner);\n  assert_eq!(members[1].email, guest.email().await);\n  assert_eq!(members[1].role, AFRole::Member);\n}\n\n#[tokio::test]\nasync fn workspace_add_member() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let other_owner = TestClient::new_user_without_ws_conn().await;\n  let member = TestClient::new_user_without_ws_conn().await;\n  let guest = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = owner.workspace_id().await;\n\n  // add client 2 to client 1's workspace\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &other_owner, AFRole::Owner)\n    .await\n    .unwrap();\n\n  // add client 3 to client 1's workspace\n  other_owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member, AFRole::Member)\n    .await\n    .unwrap();\n  other_owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Guest)\n    .await\n    .unwrap();\n\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 3);\n  assert_eq!(members[0].email, owner.email().await);\n  assert_eq!(members[0].role, AFRole::Owner);\n\n  assert_eq!(members[1].email, other_owner.email().await);\n  assert_eq!(members[1].role, AFRole::Owner);\n\n  assert_eq!(members[2].email, member.email().await);\n  assert_eq!(members[2].role, AFRole::Member);\n}\n\n#[tokio::test]\nasync fn add_workspace_member_and_owner_then_delete_all() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let member = TestClient::new_user_without_ws_conn().await;\n  let second_owner = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = owner.workspace_id().await;\n  // add client 2 to client 1's workspace\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member, AFRole::Member)\n    .await\n    .unwrap();\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &second_owner, AFRole::Owner)\n    .await\n    .unwrap();\n\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members[0].email, owner.email().await);\n  assert_eq!(members[1].email, member.email().await);\n  assert_eq!(members[2].email, second_owner.email().await);\n\n  // delete the members\n  owner\n    .try_remove_workspace_member(&workspace_id, &member)\n    .await\n    .unwrap();\n  owner\n    .try_remove_workspace_member(&workspace_id, &second_owner)\n    .await\n    .unwrap();\n  let members = owner\n    .api_client\n    .get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 1);\n  assert_eq!(members[0].email, owner.email().await);\n}\n\n#[tokio::test]\nasync fn workspace_owner_remove_self_from_workspace() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n\n  // the workspace owner can not remove 'self' from the workspace\n  let error = c1\n    .try_remove_workspace_member(&workspace_id, &c1)\n    .await\n    .unwrap_err();\n  assert_eq!(error.code, ErrorCode::NotEnoughPermissions);\n\n  let members = c1.get_workspace_members(&workspace_id).await;\n  assert_eq!(members.len(), 1);\n  assert_eq!(members[0].email, c1.email().await);\n}\n\n#[tokio::test]\nasync fn workspace_second_owner_can_not_delete_origin_owner() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let c2 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n  c1.invite_and_accepted_workspace_member(&workspace_id, &c2, AFRole::Owner)\n    .await\n    .unwrap();\n\n  let error = c2\n    .try_remove_workspace_member(&workspace_id, &c1)\n    .await\n    .unwrap_err();\n  assert_eq!(error.code, ErrorCode::NotEnoughPermissions);\n}\n\n#[tokio::test]\nasync fn user_workspace_info() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = c1.workspace_id().await;\n  let info = c1.get_user_workspace_info().await;\n  assert_eq!(info.workspaces.len(), 1);\n  assert_eq!(info.visiting_workspace.workspace_id, workspace_id);\n\n  let c2 = TestClient::new_user_without_ws_conn().await;\n  c1.invite_and_accepted_workspace_member(&workspace_id, &c2, AFRole::Owner)\n    .await\n    .unwrap();\n\n  // c2 should have 2 workspaces\n  let info = c2.get_user_workspace_info().await;\n  assert_eq!(info.workspaces.len(), 2);\n}\n\n#[tokio::test]\nasync fn get_user_workspace_info_after_open_workspace() {\n  let c1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id_c1 = c1.workspace_id().await;\n\n  let c2 = TestClient::new_user_without_ws_conn().await;\n  c1.invite_and_accepted_workspace_member(&workspace_id_c1, &c2, AFRole::Owner)\n    .await\n    .unwrap();\n\n  let info = c2.get_user_workspace_info().await;\n  let workspace_id_c2 = c1.workspace_id().await;\n  assert_eq!(info.visiting_workspace.workspace_id, workspace_id_c2);\n\n  // After open workspace, the visiting workspace should be the workspace that user just opened\n  c2.open_workspace(&workspace_id_c1).await;\n  let info = c2.get_user_workspace_info().await;\n  assert_eq!(info.visiting_workspace.workspace_id, workspace_id_c1);\n}\n\n#[tokio::test]\nasync fn member_leave_workspace_test() {\n  let c1 = TestClient::new_user().await;\n  let workspace_id_c1 = c1.workspace_id().await;\n\n  let c2 = TestClient::new_user().await;\n  c1.invite_and_accepted_workspace_member(&workspace_id_c1, &c2, AFRole::Member)\n    .await\n    .unwrap();\n  c2.api_client\n    .leave_workspace(&workspace_id_c1)\n    .await\n    .unwrap();\n\n  let members = c1.get_workspace_members(&workspace_id_c1).await;\n  assert_eq!(members.len(), 1);\n}\n\n#[tokio::test]\nasync fn owner_leave_workspace_test() {\n  let c1 = TestClient::new_user().await;\n  let workspace_id_c1 = c1.workspace_id().await;\n\n  let err = c1\n    .api_client\n    .leave_workspace(&workspace_id_c1)\n    .await\n    .unwrap_err();\n\n  // owner of workspace cannot leave the workspace\n  assert_eq!(err.code, ErrorCode::NotEnoughPermissions);\n}\n\n#[tokio::test]\nasync fn add_workspace_member_and_then_member_get_member_list() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let member = TestClient::new_user_without_ws_conn().await;\n  let guest = TestClient::new_user_without_ws_conn().await;\n\n  let workspace_id = owner.workspace_id().await;\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member, AFRole::Member)\n    .await\n    .unwrap();\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Guest)\n    .await\n    .unwrap();\n\n  // member should be able to get the member list of the workspace, guest should be excluded\n  let members = member.get_workspace_members(&workspace_id).await;\n  assert_eq!(members.len(), 2);\n\n  // guest should not be able to get the member list of the workspace, only their own info\n  // and the owner\n  let members = guest\n    .try_get_workspace_members(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(members.len(), 2);\n}\n\n#[tokio::test]\nasync fn workspace_member_through_user_id() {\n  let owner = TestClient::new_user_without_ws_conn().await;\n  let member_1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = owner.workspace_id().await;\n\n  let owner_member = owner\n    .get_workspace_member(workspace_id, owner.uid().await)\n    .await;\n  assert_eq!(owner_member.role, AFRole::Owner);\n\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &member_1, AFRole::Member)\n    .await\n    .unwrap();\n\n  let member_1_member = member_1\n    .get_workspace_member(workspace_id, member_1.uid().await)\n    .await;\n  assert_eq!(member_1_member.role, AFRole::Member);\n\n  assert_ne!(owner_member.role, member_1_member.role);\n}\n"
  },
  {
    "path": "tests/workspace/mod.rs",
    "content": "mod access_request;\nmod default_user_workspace;\nmod edit_workspace;\nmod import_test;\nmod invitation_crud;\nmod join_workspace;\nmod member_crud;\nmod page_view;\nmod person;\nmod publish;\nmod published_data;\nmod quick_note;\nmod template;\nmod workspace_crud;\nmod workspace_folder;\nmod workspace_settings;\n"
  },
  {
    "path": "tests/workspace/page_view.rs",
    "content": "use std::{collections::HashSet, time::Duration};\n\nuse client_api::entity::{QueryCollab, QueryCollabParams};\nuse client_api_test::{\n  generate_unique_registered_user, generate_unique_registered_user_client, TestClient,\n};\nuse collab::core::origin::CollabClient;\nuse collab_entity::CollabType;\nuse collab_folder::{CollabOrigin, Folder};\nuse serde_json::{json, Value};\nuse shared_entity::dto::workspace_dto::{\n  AddRecentPagesParams, AppendBlockToPageParams, CreateFolderViewParams,\n  CreatePageDatabaseViewParams, CreatePageParams, CreateSpaceParams, DuplicatePageParams,\n  FavoritePageParams, IconType, MovePageParams, PublishPageParams, SpacePermission,\n  UpdatePageExtraParams, UpdatePageIconParams, UpdatePageNameParams, UpdatePageParams,\n  UpdateSpaceParams, ViewIcon, ViewLayout,\n};\nuse tokio::time::sleep;\nuse uuid::Uuid;\n\nasync fn get_latest_folder(test_client: &TestClient, workspace_id: &Uuid) -> Folder {\n  // Wait for websocket updates\n  sleep(Duration::from_secs(1)).await;\n  let lock = test_client\n    .collabs\n    .get(workspace_id)\n    .unwrap()\n    .collab\n    .read()\n    .await;\n  let collab_type = CollabType::Folder;\n  let encoded_collab = lock\n    .encode_collab_v1(|collab| collab_type.validate_require_data(collab))\n    .unwrap();\n  let uid = test_client.uid().await;\n  Folder::from_collab_doc_state(\n    CollabOrigin::Client(CollabClient::new(uid, test_client.device_id.clone())),\n    encoded_collab.into(),\n    &workspace_id.to_string(),\n    test_client.client_id(workspace_id).await,\n  )\n  .unwrap()\n}\n\n#[tokio::test]\nasync fn get_page_view() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let todo = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .unwrap();\n  let todo_list_view_id = &todo.view_id;\n  let resp = c\n    .get_workspace_page_view(workspace_id, todo_list_view_id)\n    .await\n    .unwrap();\n  assert_eq!(resp.data.row_data.len(), 5);\n  let getting_started = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"Getting started\")\n    .unwrap();\n  let getting_started_view_id = &getting_started.view_id;\n  let resp = c\n    .get_workspace_page_view(workspace_id, getting_started_view_id)\n    .await\n    .unwrap();\n  assert_eq!(resp.data.row_data.len(), 0);\n}\n\n#[tokio::test]\nasync fn create_new_page_with_database() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let calendar_page = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Calendar,\n        name: Some(\"New calendar\".to_string()),\n        page_data: None,\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  let grid_page = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Grid,\n        name: Some(\"New grid\".to_string()),\n        page_data: None,\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  let board_page = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Grid,\n        name: Some(\"New board\".to_string()),\n        page_data: None,\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  sleep(Duration::from_secs(1)).await;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let views_under_general_space: HashSet<_> =\n    general_space.children.iter().map(|v| v.view_id).collect();\n  for view_id in &[calendar_page.view_id, grid_page.view_id, board_page.view_id] {\n    assert!(views_under_general_space.contains(view_id));\n    c.get_workspace_page_view(workspace_id, view_id)\n      .await\n      .unwrap();\n  }\n}\n\n#[tokio::test]\nasync fn create_new_folder_view() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let page = c\n    .create_folder_view(\n      workspace_id,\n      &CreateFolderViewParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Document,\n        name: Some(\"New document\".to_string()),\n        view_id: None,\n        database_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  sleep(Duration::from_secs(1)).await;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let view = general_space\n    .children\n    .iter()\n    .find(|v| v.view_id == page.view_id)\n    .unwrap();\n  assert_eq!(view.name, \"New document\");\n}\n\n#[tokio::test]\nasync fn create_new_document_page() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let page = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Document,\n        name: Some(\"New document\".to_string()),\n        page_data: None,\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  let page_with_initial_data = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Document,\n        name: Some(\"Message extracted from why is the sky blue\".to_string()),\n        page_data: Some(json!({\n          \"type\": \"page\",\n          \"children\": [\n            {\n              \"type\": \"paragraph\",\n              \"data\": {\n                \"delta\": [\n                  {\n                    \"insert\": \"The sky appears blue due to a phenomenon called Rayleigh scattering.\"\n                  }\n                ]\n              }\n            },\n          ]\n        })),\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  sleep(Duration::from_secs(1)).await;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let view = general_space\n    .children\n    .iter()\n    .find(|v| v.view_id == page.view_id)\n    .unwrap();\n  assert_eq!(view.name, \"New document\");\n  c.get_collab(QueryCollabParams {\n    workspace_id,\n    inner: QueryCollab {\n      object_id: page.view_id,\n      collab_type: CollabType::Document,\n    },\n  })\n  .await\n  .unwrap();\n  general_space\n    .children\n    .iter()\n    .find(|v| v.view_id == page_with_initial_data.view_id)\n    .unwrap();\n  c.get_collab(QueryCollabParams {\n    workspace_id,\n    inner: QueryCollab {\n      object_id: page_with_initial_data.view_id,\n      collab_type: CollabType::Document,\n    },\n  })\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn append_block_to_page() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let getting_started = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"Getting started\")\n    .unwrap();\n  let getting_started_view_id = &getting_started.view_id;\n  c.append_block_to_page(\n    workspace_id,\n    getting_started_view_id,\n    &AppendBlockToPageParams {\n      blocks: vec![json!({\n        \"type\": \"paragraph\",\n        \"data\": {\n          \"delta\": [\n            {\n              \"insert\": \"The sky appears blue due to a phenomenon called Rayleigh scattering.\"\n            }\n          ]\n        }\n      })],\n    },\n  )\n  .await\n  .unwrap();\n}\n\n#[tokio::test]\nasync fn create_new_chat_page() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let page = c\n    .create_workspace_page_view(\n      workspace_id,\n      &CreatePageParams {\n        parent_view_id: general_space.view_id,\n        layout: ViewLayout::Chat,\n        name: Some(\"New chat\".to_string()),\n        page_data: None,\n        view_id: None,\n        collab_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  sleep(Duration::from_secs(1)).await;\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  general_space\n    .children\n    .iter()\n    .find(|v| v.view_id == page.view_id)\n    .unwrap();\n  assert_eq!(\n    c.get_chat_settings(&workspace_id, &page.view_id.to_string())\n      .await\n      .unwrap()\n      .name,\n    \"New chat\"\n  );\n}\n\n#[tokio::test]\nasync fn move_page_to_another_space() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = folder_view\n    .children\n    .iter()\n    .find(|v| v.name == \"General\")\n    .unwrap()\n    .clone();\n  let todo_view_id = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .map(|v| v.view_id)\n    .unwrap();\n  let shared_space = &folder_view\n    .children\n    .iter()\n    .find(|v| v.name == \"Shared\")\n    .unwrap()\n    .clone();\n  web_client\n    .api_client\n    .move_workspace_page_view(\n      workspace_id,\n      &todo_view_id,\n      &MovePageParams {\n        new_parent_view_id: shared_space.view_id.to_string(),\n        prev_view_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let first_children_id = folder\n    .get_view(&shared_space.view_id.to_string(), web_client.uid().await)\n    .unwrap()\n    .children[0]\n    .id\n    .parse::<Uuid>()\n    .unwrap();\n  assert_eq!(first_children_id, todo_view_id);\n}\n\n#[tokio::test]\nasync fn move_page_to_trash_then_restore() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let view_ids_to_be_deleted = [\n    general_space.children[0].view_id,\n    general_space.children[1].view_id,\n  ];\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  for view_id in view_ids_to_be_deleted.iter() {\n    web_client\n      .api_client\n      .move_workspace_page_view_to_trash(workspace_id, view_id)\n      .await\n      .unwrap();\n  }\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let views_in_trash_for_app = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .flat_map(|v| Uuid::parse_str(&v.id).ok())\n    .collect::<HashSet<_>>();\n  for view_id in view_ids_to_be_deleted.iter() {\n    assert!(views_in_trash_for_app.contains(view_id));\n  }\n  let views_in_trash_for_web = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .map(|v| v.view.view_id)\n    .collect::<HashSet<_>>();\n  for view_id in view_ids_to_be_deleted.iter() {\n    assert!(views_in_trash_for_web.contains(view_id));\n  }\n\n  web_client\n    .api_client\n    .restore_workspace_page_view_from_trash(workspace_id, &view_ids_to_be_deleted[0])\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  assert!(!folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .any(|v| v.id == view_ids_to_be_deleted[0].to_string()));\n  let view_found = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .any(|v| v.view.view_id == view_ids_to_be_deleted[0]);\n  assert!(!view_found);\n  web_client\n    .api_client\n    .restore_all_workspace_page_views_from_trash(workspace_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  assert!(!folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .any(|v| v.id == view_ids_to_be_deleted[1].to_string()));\n  let view_found = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .any(|v| v.view.view_id == view_ids_to_be_deleted[1]);\n  assert!(!view_found);\n}\n\n#[tokio::test]\nasync fn move_page_with_child_to_trash_then_restore() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .move_workspace_page_view_to_trash(workspace_id, &general_space.view_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let views_in_trash_for_app = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .flat_map(|v| Uuid::parse_str(&v.id).ok())\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_app.contains(&general_space.view_id));\n  for view in general_space.children.iter() {\n    assert!(!views_in_trash_for_app.contains(&view.view_id));\n  }\n  let views_in_trash_for_web = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .map(|v| v.view.view_id)\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_web.contains(&general_space.view_id));\n\n  web_client\n    .api_client\n    .restore_workspace_page_view_from_trash(workspace_id, &general_space.view_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  assert!(!folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .any(|v| v.id == general_space.view_id.to_string()));\n  let view_found = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .any(|v| v.view.view_id == general_space.view_id);\n  assert!(!view_found);\n}\n\n#[tokio::test]\nasync fn move_page_with_child_to_trash_then_delete_permanently() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .move_workspace_page_view_to_trash(workspace_id, &general_space.view_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let views_in_trash_for_app = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .flat_map(|v| Uuid::parse_str(&v.id).ok())\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_app.contains(&general_space.view_id));\n  for view in general_space.children.iter() {\n    assert!(!views_in_trash_for_app.contains(&view.view_id));\n  }\n  let views_in_trash_for_web = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .map(|v| v.view.view_id)\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_web.contains(&general_space.view_id));\n\n  web_client\n    .api_client\n    .delete_workspace_page_view_from_trash(workspace_id, &general_space.view_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  assert!(folder\n    .get_view(&general_space.view_id.to_string(), uid)\n    .is_none());\n  assert!(!folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .any(|v| v.id == general_space.view_id.to_string()));\n  let view_found = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .any(|v| v.view.view_id == general_space.view_id);\n  assert!(!view_found);\n}\n\n#[tokio::test]\nasync fn move_page_with_child_to_trash_then_delete_all_permanently() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .move_workspace_page_view_to_trash(workspace_id, &general_space.view_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let views_in_trash_for_app = folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .flat_map(|v| Uuid::parse_str(&v.id).ok())\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_app.contains(&general_space.view_id));\n  for view in general_space.children.iter() {\n    assert!(!views_in_trash_for_app.contains(&view.view_id));\n  }\n  let views_in_trash_for_web = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .map(|v| v.view.view_id)\n    .collect::<HashSet<_>>();\n  assert!(views_in_trash_for_web.contains(&general_space.view_id));\n\n  web_client\n    .api_client\n    .delete_all_workspace_page_views_from_trash(workspace_id)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  assert!(folder\n    .get_view(&general_space.view_id.to_string(), uid)\n    .is_none());\n  assert!(!folder\n    .get_my_trash_sections(uid)\n    .iter()\n    .any(|v| v.id == general_space.view_id.to_string()));\n  let view_found = web_client\n    .api_client\n    .get_workspace_trash(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .any(|v| v.view.view_id == general_space.view_id);\n  assert!(!view_found);\n}\n\n#[tokio::test]\nasync fn update_page() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let view_id_to_be_updated = general_space.children[0].view_id;\n  web_client\n    .api_client\n    .update_workspace_page_view(\n      workspace_id,\n      &view_id_to_be_updated,\n      &UpdatePageParams {\n        name: \"New Name\".to_string(),\n        icon: Some(ViewIcon {\n          ty: IconType::Emoji,\n          value: \"🚀\".to_string(),\n        }),\n        is_locked: None,\n        extra: Some(json!({\"is_pinned\": true})),\n      },\n    )\n    .await\n    .unwrap();\n\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let updated_view = folder\n    .get_view(&view_id_to_be_updated.to_string(), uid)\n    .unwrap();\n  assert_eq!(updated_view.name, \"New Name\");\n  assert_eq!(\n    updated_view.icon,\n    Some(collab_folder::ViewIcon {\n      ty: collab_folder::IconType::Emoji,\n      value: \"🚀\".to_string(),\n    })\n  );\n  assert_eq!(\n    updated_view.extra,\n    Some(json!({\"is_pinned\": true}).to_string())\n  );\n  assert_eq!(updated_view.is_locked, None);\n  web_client\n    .api_client\n    .update_page_name(\n      workspace_id,\n      &view_id_to_be_updated,\n      &UpdatePageNameParams {\n        name: \"Another Name\".to_string(),\n      },\n    )\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .update_page_icon(\n      workspace_id,\n      &view_id_to_be_updated,\n      &UpdatePageIconParams {\n        icon: ViewIcon {\n          ty: IconType::Emoji,\n          value: \"😎\".to_string(),\n        },\n      },\n    )\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .update_page_extra(\n      workspace_id,\n      &view_id_to_be_updated,\n      &UpdatePageExtraParams {\n        extra: json!({\"is_pinned\": false}).to_string(),\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let updated_view = folder\n    .get_view(&view_id_to_be_updated.to_string(), uid)\n    .unwrap();\n  assert_eq!(updated_view.name, \"Another Name\");\n  assert_eq!(\n    updated_view.icon,\n    Some(collab_folder::ViewIcon {\n      ty: collab_folder::IconType::Emoji,\n      value: \"😎\".to_string(),\n    })\n  );\n  assert_eq!(\n    updated_view.extra,\n    Some(json!({\"is_pinned\": false}).to_string())\n  );\n  web_client\n    .api_client\n    .remove_page_icon(workspace_id, &view_id_to_be_updated)\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let updated_view = folder\n    .get_view(&view_id_to_be_updated.to_string(), uid)\n    .unwrap();\n  assert_eq!(updated_view.icon, None);\n}\n\n#[tokio::test]\nasync fn favorite_page() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let favorite_view_id = general_space.children[0].view_id;\n  web_client\n    .api_client\n    .favorite_page_view(\n      workspace_id,\n      &favorite_view_id,\n      &FavoritePageParams {\n        is_favorite: true,\n        is_pinned: true,\n      },\n    )\n    .await\n    .unwrap();\n\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let favorite_view = folder.get_view(&favorite_view_id.to_string(), uid).unwrap();\n  assert!(favorite_view.is_favorite);\n}\n\n#[tokio::test]\nasync fn create_space() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let public_space = web_client\n    .api_client\n    .create_space(\n      workspace_id,\n      &CreateSpaceParams {\n        space_permission: SpacePermission::PublicToAll,\n        name: \"Public Space\".to_string(),\n        space_icon: \"space_icon_1\".to_string(),\n        space_icon_color: \"0xFFA34AFD\".to_string(),\n        view_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  web_client\n    .api_client\n    .create_space(\n      workspace_id,\n      &CreateSpaceParams {\n        space_permission: SpacePermission::Private,\n        name: \"Private Space\".to_string(),\n        space_icon: \"space_icon_2\".to_string(),\n        space_icon_color: \"0xFFA34AFD\".to_string(),\n        view_id: None,\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let view = folder\n    .get_view(&public_space.view_id.to_string(), uid)\n    .unwrap();\n  let space_info: Value = serde_json::from_str(view.extra.as_ref().unwrap()).unwrap();\n  assert!(space_info[\"is_space\"].as_bool().unwrap());\n  assert_eq!(\n    space_info[\"space_permission\"].as_u64().unwrap() as u8,\n    SpacePermission::PublicToAll as u8\n  );\n  assert_eq!(space_info[\"space_icon\"].as_str().unwrap(), \"space_icon_1\");\n  assert_eq!(\n    space_info[\"space_icon_color\"].as_str().unwrap(),\n    \"0xFFA34AFD\"\n  );\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), Some(workspace_id))\n    .await\n    .unwrap();\n  folder_view\n    .children\n    .iter()\n    .find(|v| v.name == \"Public Space\")\n    .unwrap();\n  let private_space = folder_view\n    .children\n    .iter()\n    .find(|v| v.name == \"Private Space\")\n    .unwrap();\n  assert!(private_space.is_private);\n\n  web_client\n    .api_client\n    .update_space(\n      workspace_id,\n      &private_space.view_id,\n      &UpdateSpaceParams {\n        space_permission: SpacePermission::PublicToAll,\n        name: \"Renamed Space\".to_string(),\n        space_icon: \"space_icon_3\".to_string(),\n        space_icon_color: \"#000000\".to_string(),\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let view = folder\n    .get_view(&private_space.view_id.to_string(), uid)\n    .unwrap();\n  let space_info: Value = serde_json::from_str(view.extra.as_ref().unwrap()).unwrap();\n  assert!(space_info[\"is_space\"].as_bool().unwrap());\n  assert_eq!(\n    space_info[\"space_permission\"].as_u64().unwrap() as u8,\n    SpacePermission::PublicToAll as u8\n  );\n  assert_eq!(space_info[\"space_icon\"].as_str().unwrap(), \"space_icon_3\");\n  assert_eq!(space_info[\"space_icon_color\"].as_str().unwrap(), \"#000000\");\n}\n\n#[tokio::test]\nasync fn publish_page() {\n  let registered_user = generate_unique_registered_user().await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let workspace_id = web_client.workspace_id().await;\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let database_page_id = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .unwrap()\n    .view_id;\n  let document_page_id = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"Getting started\")\n    .unwrap()\n    .view_id;\n  let page_to_be_published = vec![database_page_id, document_page_id];\n  for view_id in &page_to_be_published {\n    web_client\n      .api_client\n      .publish_page(\n        workspace_id,\n        view_id,\n        &PublishPageParams {\n          publish_name: None,\n          visible_database_view_ids: None,\n          comments_enabled: None,\n          duplicate_enabled: None,\n        },\n      )\n      .await\n      .unwrap();\n  }\n  let publish_namespace = web_client\n    .api_client\n    .get_workspace_publish_namespace(&workspace_id)\n    .await\n    .unwrap();\n  let published_view = web_client\n    .api_client\n    .get_published_outline(&publish_namespace)\n    .await\n    .unwrap();\n  let published_view_ids: HashSet<_> = published_view\n    .children\n    .iter()\n    .find(|v| v.name == \"General\")\n    .unwrap()\n    .children\n    .iter()\n    .flat_map(|v| Uuid::parse_str(&v.view_id))\n    .collect();\n  for view_id in &page_to_be_published {\n    assert!(published_view_ids.contains(view_id));\n  }\n  for view_id in &page_to_be_published {\n    web_client\n      .api_client\n      .unpublish_page(workspace_id, view_id)\n      .await\n      .unwrap();\n  }\n  let published_view = web_client\n    .api_client\n    .get_published_outline(&publish_namespace)\n    .await\n    .unwrap();\n  assert_eq!(published_view.children.len(), 0);\n}\n\n#[tokio::test]\nasync fn duplicate_view() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  web_client\n    .api_client\n    .duplicate_view_and_children(\n      workspace_id,\n      &general_space.view_id,\n      &DuplicatePageParams {\n        suffix: Some(\" (Copy)\".to_string()),\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let duplicated_space_id = folder\n    .get_view(&workspace_id.to_string(), uid)\n    .unwrap()\n    .children\n    .iter()\n    .find(|v| folder.get_view(&v.id, uid).unwrap().name == \"General (Copy)\")\n    .unwrap()\n    .id\n    .clone();\n  let duplicated_views = folder.get_view_recursively(&duplicated_space_id, uid);\n  assert_eq!(duplicated_views.len(), 6);\n}\n\n#[tokio::test]\nasync fn create_database_page_view() {\n  let registered_user = generate_unique_registered_user().await;\n  let mut app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let web_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let uid = web_client.uid().await;\n  let workspace_id = app_client.workspace_id().await;\n  app_client.open_workspace_collab(workspace_id).await;\n  app_client\n    .wait_object_sync_complete(&workspace_id)\n    .await\n    .unwrap();\n  let folder_view = web_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let todo_folder_view = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .unwrap();\n  let todo_list_view_id = todo_folder_view.view_id;\n  web_client\n    .api_client\n    .create_database_view(\n      workspace_id,\n      &todo_list_view_id,\n      &CreatePageDatabaseViewParams {\n        layout: ViewLayout::Grid,\n        name: Some(\"Grid View\".to_string()),\n      },\n    )\n    .await\n    .unwrap();\n  let folder = get_latest_folder(&app_client, &workspace_id).await;\n  let todo_view = folder\n    .get_view(&todo_list_view_id.to_string(), uid)\n    .unwrap();\n  let grid_view = todo_view\n    .children\n    .iter()\n    .find_map(|v| {\n      folder.get_view(&v.id, uid).map(|view| {\n        if view.name == \"Grid View\" {\n          Some(view)\n        } else {\n          None\n        }\n      })\n    })\n    .flatten()\n    .unwrap();\n  let page_collab = web_client\n    .api_client\n    .get_workspace_page_view(workspace_id, &grid_view.id.parse().unwrap())\n    .await\n    .unwrap();\n  assert_eq!(page_collab.data.row_data.len(), 5);\n  assert_eq!(page_collab.view.layout, ViewLayout::Grid);\n}\n\n#[tokio::test]\nasync fn add_recent_pages() {\n  let registered_user = generate_unique_registered_user().await;\n  let app_client = TestClient::user_with_new_device(registered_user.clone()).await;\n  let workspace_id = app_client.workspace_id().await;\n  let folder_view = app_client\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let child_view_ids: Vec<_> = general_space.children.iter().map(|v| v.view_id).collect();\n  for _ in 0..2 {\n    for view_id in &child_view_ids {\n      app_client\n        .api_client\n        .add_recent_pages(\n          workspace_id,\n          &AddRecentPagesParams {\n            recent_view_ids: vec![view_id.to_string()],\n          },\n        )\n        .await\n        .unwrap();\n    }\n  }\n  let recent_section_ids = app_client\n    .api_client\n    .get_workspace_recent(&workspace_id)\n    .await\n    .unwrap()\n    .views\n    .iter()\n    .map(|v| v.view.view_id)\n    .collect::<Vec<_>>();\n  assert_eq!(recent_section_ids, child_view_ids)\n}\n"
  },
  {
    "path": "tests/workspace/person.rs",
    "content": "use std::collections::HashSet;\n\nuse client_api::entity::{AFRole, PageMentionUpdate, WorkspaceMemberProfile};\nuse client_api_test::TestClient;\n\n#[tokio::test]\nasync fn workspace_mentionable_persons_crud() {\n  let owner = TestClient::new_user().await;\n  let guest = TestClient::new_user().await;\n  let guest_name = guest.api_client.get_profile().await.unwrap().name.unwrap();\n  let workspace_id = owner.workspace_id().await;\n  owner\n    .invite_and_accepted_workspace_member(&workspace_id, &guest, AFRole::Guest)\n    .await\n    .unwrap();\n  let workspaces = owner.api_client.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n  owner\n    .api_client\n    .update_workspace_member_profile(\n      &workspace_id,\n      &WorkspaceMemberProfile {\n        name: \"name override\".to_string(),\n        avatar_url: Some(\"avatar url override\".to_string()),\n        cover_image_url: Some(\"cover image url\".to_string()),\n        custom_image_url: Some(\"custom image url\".to_string()),\n        description: Some(\"description override\".to_string()),\n      },\n    )\n    .await\n    .unwrap();\n\n  let mentionable_persons = owner\n    .api_client\n    .list_workspace_mentionable_persons(&workspace_id)\n    .await\n    .unwrap()\n    .persons;\n  assert_eq!(mentionable_persons.len(), 2);\n  let mentionable_person_names: HashSet<String> =\n    mentionable_persons.iter().map(|p| p.name.clone()).collect();\n  assert!(mentionable_person_names.contains(\"name override\"));\n  assert!(mentionable_person_names.contains(&guest_name));\n  let person_id = mentionable_persons\n    .iter()\n    .find(|p| p.name == guest_name)\n    .unwrap()\n    .uuid;\n  let mentionable_person = owner\n    .api_client\n    .get_workspace_mentionable_person(&workspace_id, &person_id)\n    .await\n    .unwrap();\n  assert_eq!(mentionable_person.name, guest_name);\n\n  let folder_view = owner\n    .api_client\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  let general_space = &folder_view\n    .children\n    .into_iter()\n    .find(|v| v.name == \"General\")\n    .unwrap();\n  let todo = general_space\n    .children\n    .iter()\n    .find(|v| v.name == \"To-dos\")\n    .unwrap();\n  let view_id = todo.view_id;\n  owner\n    .api_client\n    .update_page_mention(\n      &workspace_id,\n      &view_id,\n      &PageMentionUpdate {\n        person_id,\n        block_id: None,\n        require_notification: false,\n        view_name: \"To-dos\".to_string(),\n      },\n    )\n    .await\n    .unwrap();\n  let mentionable_persons_with_last_mentioned_time = owner\n    .api_client\n    .list_workspace_mentionable_persons(&workspace_id)\n    .await\n    .unwrap();\n  assert_eq!(\n    mentionable_persons_with_last_mentioned_time.persons.len(),\n    2\n  );\n  assert_eq!(\n    mentionable_persons_with_last_mentioned_time.persons[0].uuid,\n    person_id\n  );\n  let last_mentioned_at = mentionable_persons_with_last_mentioned_time.persons[0].last_mentioned_at;\n  assert!(last_mentioned_at.is_some());\n\n  let mentionable_persons_with_access = owner\n    .api_client\n    .list_page_mentionable_persons(&workspace_id, &view_id)\n    .await\n    .unwrap()\n    .persons;\n  assert_eq!(mentionable_persons_with_access.len(), 2);\n  let guest_can_access = mentionable_persons_with_access\n    .iter()\n    .find(|p| p.person.name == guest_name)\n    .unwrap()\n    .can_access_page;\n  assert!(!guest_can_access);\n  let owner_can_access = mentionable_persons_with_access\n    .iter()\n    .find(|p| p.person.name == \"name override\")\n    .unwrap()\n    .can_access_page;\n  assert!(owner_can_access);\n}\n"
  },
  {
    "path": "tests/workspace/publish.rs",
    "content": "use crate::workspace::published_data::{self};\nuse app_error::ErrorCode;\nuse appflowy_cloud::biz::collab::folder_view::collab_folder_to_folder_view;\nuse appflowy_cloud::biz::collab::utils::collab_from_doc_state;\nuse client_api::entity::{\n  AFRole, GlobalComment, PatchPublishedCollab, PublishCollabItem, PublishCollabMetadata,\n  PublishInfoMeta,\n};\nuse client_api_test::TestClient;\nuse client_api_test::{generate_unique_registered_user_client, localhost_client};\nuse collab::core::collab::default_client_id;\nuse collab::util::MapExt;\nuse collab_database::database::DatabaseBody;\nuse collab_database::database_trait::NoPersistenceDatabaseCollabService;\nuse collab_database::entity::FieldType;\nuse collab_database::rows::RowDetail;\nuse collab_database::views::DatabaseViews;\nuse collab_database::workspace_database::WorkspaceDatabase;\nuse collab_document::document::Document;\nuse collab_entity::CollabType;\nuse collab_folder::{CollabOrigin, Folder};\nuse itertools::Itertools;\nuse serde::{Deserialize, Serialize};\nuse shared_entity::dto::auth_dto::UpdateUserParams;\nuse shared_entity::dto::publish_dto::PublishDatabaseData;\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\nuse std::thread::sleep;\nuse std::time::Duration;\nuse uuid::Uuid;\nuse yrs::block::ClientID;\n\n#[tokio::test]\nasync fn test_set_publish_namespace_set() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&c).await;\n\n  {\n    // can get namespace before setting, which is random string\n    let _ = c\n      .get_workspace_publish_namespace(&workspace_id)\n      .await\n      .unwrap();\n  }\n\n  let new_namespace = format!(\"namespace_{}\", Uuid::new_v4());\n  c.set_workspace_publish_namespace(&workspace_id, new_namespace.clone())\n    .await\n    .unwrap();\n\n  {\n    // another workspace cannot have the same namespace\n    let (c2, _user) = generate_unique_registered_user_client().await;\n    let workspace_id_2 = get_first_workspace(&c2).await;\n    let err = c2\n      .set_workspace_publish_namespace(&workspace_id_2, new_namespace.clone())\n      .await\n      .unwrap_err();\n    assert_eq!(\n      err.code,\n      ErrorCode::PublishNamespaceAlreadyTaken,\n      \"{:?}\",\n      err\n    );\n  }\n\n  {\n    // cannot set the same namespace\n    let err = c\n      .set_workspace_publish_namespace(&workspace_id, new_namespace.clone())\n      .await\n      .err()\n      .unwrap();\n    assert_eq!(\n      err.code,\n      ErrorCode::PublishNamespaceAlreadyTaken,\n      \"{:?}\",\n      err\n    );\n  }\n  let new_namespace_2 = Uuid::new_v4().to_string();\n  {\n    // can replace the namespace\n    c.set_workspace_publish_namespace(&workspace_id, new_namespace_2.clone())\n      .await\n      .unwrap();\n\n    let got_namespace = c\n      .get_workspace_publish_namespace(&workspace_id)\n      .await\n      .unwrap();\n    assert_eq!(got_namespace, new_namespace_2);\n  }\n  {\n    // cannot set namespace with invalid chars\n    let err = c\n      .set_workspace_publish_namespace(&workspace_id, \"/|(*&)(&#@!\".to_string()) // invalid chars\n      .await\n      .err()\n      .unwrap();\n    assert_eq!(\n      err.code,\n      ErrorCode::CustomNamespaceInvalidCharacter,\n      \"{:?}\",\n      err\n    );\n  }\n}\n\n#[tokio::test]\nasync fn test_publish_doc() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&c).await;\n  let my_namespace = Uuid::new_v4().to_string();\n  c.set_workspace_publish_namespace(&workspace_id, my_namespace.clone())\n    .await\n    .unwrap();\n\n  {\n    // Invalid publish name\n    let err = c\n      .publish_collabs::<MyCustomMetadata, &[u8]>(\n        &workspace_id,\n        vec![PublishCollabItem {\n          meta: PublishCollabMetadata {\n            view_id: Uuid::new_v4(),\n            publish_name: \"(*&^%$#!\".to_string(),\n            metadata: MyCustomMetadata {\n              title: \"my_title_1\".to_string(),\n            },\n          },\n          data: \"yrs_encoded_data_1\".as_bytes(),\n          comments_enabled: true,\n          duplicate_enabled: true,\n        }],\n      )\n      .await\n      .unwrap_err();\n    assert_eq!(\n      err.code,\n      ErrorCode::PublishNameInvalidCharacter,\n      \"{:?}\",\n      err\n    );\n    // Publish name too long\n    let err = c\n      .publish_collabs::<MyCustomMetadata, &[u8]>(\n        &workspace_id,\n        vec![PublishCollabItem {\n          meta: PublishCollabMetadata {\n            view_id: Uuid::new_v4(),\n            publish_name: \"a\".repeat(1001),\n            metadata: MyCustomMetadata {\n              title: \"my_title_1\".to_string(),\n            },\n          },\n          data: \"yrs_encoded_data_1\".as_bytes(),\n          comments_enabled: true,\n          duplicate_enabled: true,\n        }],\n      )\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::PublishNameTooLong, \"{:?}\", err);\n  }\n\n  let publish_name_1 = \"publish_name-1\";\n  let view_id_1 = Uuid::new_v4();\n  let publish_name_2 = \"publish_name-2\";\n  let view_id_2 = Uuid::new_v4();\n\n  // User publishes two collabs\n  c.publish_collabs::<MyCustomMetadata, &[u8]>(\n    &workspace_id,\n    vec![\n      PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: view_id_1,\n          publish_name: publish_name_1.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_1\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_1\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      },\n      PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: view_id_2,\n          publish_name: publish_name_2.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_2\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_2\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      },\n    ],\n  )\n  .await\n  .unwrap();\n\n  // User cannot publish another view_id with the same publish name\n  let err = c\n    .publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: Uuid::new_v4(),\n          publish_name: publish_name_1.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"some_other_title\".to_string(),\n          },\n        },\n        data: \"some_other_yrs_data\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap_err();\n  assert_eq!(err.code, ErrorCode::PublishNameAlreadyExists, \"{:?}\", err);\n\n  {\n    // Check that the published collabs are listed\n    let published_view_infos = c.list_published_views(&workspace_id).await.unwrap();\n    assert_eq!(published_view_infos.len(), 2);\n    let view_info_1 = published_view_infos\n      .iter()\n      .find(|view_info| view_info.info.publish_name == publish_name_1)\n      .unwrap();\n    assert_eq!(view_info_1.info.view_id, view_id_1);\n\n    let view_info_2 = published_view_infos\n      .iter()\n      .find(|view_info| view_info.info.publish_name == publish_name_2)\n      .unwrap();\n    assert_eq!(view_info_2.info.view_id, view_id_2);\n  }\n\n  {\n    // Non login user should be able to view the published collab\n    let guest_client = localhost_client();\n    let published_collab = guest_client\n      .get_published_collab::<MyCustomMetadata>(&my_namespace, publish_name_1)\n      .await\n      .unwrap();\n    assert_eq!(published_collab.title, \"my_title_1\");\n\n    let publish_info = guest_client\n      .get_published_collab_info(&view_id_1)\n      .await\n      .unwrap();\n    assert_eq!(publish_info.namespace, my_namespace.clone());\n    assert_eq!(publish_info.publish_name, publish_name_1);\n    assert_eq!(publish_info.view_id, view_id_1);\n\n    let blob = guest_client\n      .get_published_collab_blob(&my_namespace, publish_name_1)\n      .await\n      .unwrap();\n    assert_eq!(blob, \"yrs_encoded_data_1\");\n\n    let publish_info = guest_client\n      .get_published_collab_info(&view_id_2)\n      .await\n      .unwrap();\n    assert_eq!(publish_info.namespace, my_namespace.clone());\n    assert_eq!(publish_info.publish_name, publish_name_2);\n    assert_eq!(publish_info.view_id, view_id_2);\n\n    let blob = guest_client\n      .get_published_collab_blob(&my_namespace, publish_name_2)\n      .await\n      .unwrap();\n    assert_eq!(blob, \"yrs_encoded_data_2\");\n  }\n\n  // updates data\n  c.publish_collabs::<MyCustomMetadata, &[u8]>(\n    &workspace_id,\n    vec![\n      PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: view_id_1,\n          publish_name: publish_name_1.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_1\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_3\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      },\n      PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: view_id_2,\n          publish_name: publish_name_2.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_2\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_4\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      },\n    ],\n  )\n  .await\n  .unwrap();\n\n  {\n    // should see updated data\n    let guest_client = localhost_client();\n    let blob = guest_client\n      .get_published_collab_blob(&my_namespace, publish_name_1)\n      .await\n      .unwrap();\n    assert_eq!(blob, \"yrs_encoded_data_3\");\n\n    let blob = guest_client\n      .get_published_collab_blob(&my_namespace, publish_name_2)\n      .await\n      .unwrap();\n    assert_eq!(blob, \"yrs_encoded_data_4\");\n  }\n\n  {\n    // Try to get default publish view info but not set\n    let err = c\n      .get_default_publish_view_info(&workspace_id)\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n\n    // Set publish view as default workspace view\n    c.set_default_publish_view(&workspace_id, view_id_1.to_owned())\n      .await\n      .unwrap();\n\n    // Get default publish view\n    let publish_info = c\n      .get_default_publish_view_info(&workspace_id)\n      .await\n      .unwrap();\n    assert_eq!(publish_info.view_id, view_id_1);\n\n    // Public can use namespace to get default publish view info and view metadata\n    let default_info_meta: PublishInfoMeta<MyCustomMetadata> = localhost_client()\n      .get_default_published_collab(&my_namespace)\n      .await\n      .unwrap();\n    assert_eq!(default_info_meta.info.view_id, view_id_1);\n    assert_eq!(default_info_meta.meta.title, \"my_title_1\");\n\n    // Owner of workspace unset the default publish view\n    c.delete_default_publish_view(&workspace_id).await.unwrap();\n\n    // Public can no longer get default publish view info\n    let err = localhost_client()\n      .get_default_published_collab::<PublishInfoMeta<MyCustomMetadata>>(&my_namespace)\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n  }\n\n  {\n    // User cannot change `publish_name` if the `publish_name` already exists\n    // for the same workspace\n    let err = c\n      .patch_published_collabs(\n        &workspace_id,\n        &[PatchPublishedCollab {\n          view_id: view_id_1,\n          publish_name: Some(publish_name_2.to_string()),\n          comments_enabled: None,\n          duplicate_enabled: None,\n        }],\n      )\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::PublishNameAlreadyExists, \"{:?}\", err);\n\n    let new_publish_name_1 = \"new-publish-name-1\".to_string();\n\n    // User change publish name\n    c.patch_published_collabs(\n      &workspace_id,\n      &[PatchPublishedCollab {\n        view_id: view_id_1,\n        publish_name: Some(new_publish_name_1.to_string()),\n        comments_enabled: None,\n        duplicate_enabled: None,\n      }],\n    )\n    .await\n    .unwrap();\n\n    // Guest now cannot access the collab using old publish name\n    let guest_client = localhost_client();\n    let err = guest_client\n      .get_published_collab::<MyCustomMetadata>(&my_namespace, publish_name_1)\n      .await\n      .err()\n      .unwrap();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n\n    // Guest now access the collab using new publish name\n    let guest_client = localhost_client();\n    let _ = guest_client\n      .get_published_collab::<MyCustomMetadata>(&my_namespace, &new_publish_name_1)\n      .await\n      .unwrap();\n\n    // Switch back to old publish name\n    c.patch_published_collabs(\n      &workspace_id,\n      &[PatchPublishedCollab {\n        view_id: view_id_1,\n        publish_name: Some(publish_name_1.to_string()),\n        comments_enabled: None,\n        duplicate_enabled: None,\n      }],\n    )\n    .await\n    .unwrap();\n\n    // Guest can access the collab using the orginal publish name\n    let guest_client = localhost_client();\n    let _ = guest_client\n      .get_published_collab::<MyCustomMetadata>(&my_namespace, publish_name_1)\n      .await\n      .unwrap();\n  }\n\n  // user unpublish collabs\n  c.unpublish_collabs(&workspace_id, &[view_id_1, view_id_2])\n    .await\n    .unwrap();\n\n  {\n    // Deleted collab should not be accessible\n    let guest_client = localhost_client();\n    let err = guest_client\n      .get_published_collab::<MyCustomMetadata>(&my_namespace, publish_name_1)\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n\n    let guest_client = localhost_client();\n    let err = guest_client\n      .get_published_collab_blob(&my_namespace, publish_name_1)\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n\n    // default publish view should not be accessible\n    let err = c\n      .get_default_publish_view_info(&workspace_id)\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n  }\n\n  {\n    // check that the published collabs are removed\n    // listing published collab should return empty\n    let published_infos = c.list_published_views(&workspace_id).await.unwrap();\n    assert_eq!(published_infos.len(), 0);\n  }\n}\n\n#[tokio::test]\nasync fn test_publish_comments() {\n  let (page_owner_client, _) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&page_owner_client).await;\n  let published_view_namespace = Uuid::new_v4().to_string();\n  page_owner_client\n    .set_workspace_publish_namespace(&workspace_id, published_view_namespace)\n    .await\n    .unwrap();\n  page_owner_client\n    .update_user(UpdateUserParams {\n      name: Some(\"PageOwner\".to_string()),\n      password: None,\n      email: None,\n      metadata: None,\n    })\n    .await\n    .unwrap();\n\n  let publish_name = \"published-view\";\n  let view_id = Uuid::new_v4();\n  page_owner_client\n    .publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id,\n          publish_name: publish_name.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"some_title\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_1\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap();\n\n  // Test if only authenticated users can create\n  let page_owner_comment_content = \"comment from page owner\";\n  {\n    page_owner_client\n      .create_comment_on_published_view(&view_id, page_owner_comment_content, &None)\n      .await\n      .unwrap();\n    let comments = page_owner_client\n      .get_published_view_comments(&view_id)\n      .await\n      .unwrap()\n      .comments;\n    assert_eq!(comments.len(), 1);\n    assert_eq!(comments[0].content, page_owner_comment_content);\n  }\n\n  let (first_user_client, _) = generate_unique_registered_user_client().await;\n  let first_user_comment_content = \"comment from first authenticated user\";\n  // This is to ensure that the second comment creation timestamp is later than the first one\n  sleep(Duration::from_millis(1));\n  first_user_client\n    .create_comment_on_published_view(&view_id, first_user_comment_content, &None)\n    .await\n    .unwrap();\n  first_user_client\n    .update_user(UpdateUserParams {\n      name: Some(\"User1\".to_string()),\n      password: None,\n      email: None,\n      metadata: None,\n    })\n    .await\n    .unwrap();\n  let guest_client = localhost_client();\n  let result = guest_client\n    .create_comment_on_published_view(&view_id, \"comment from anonymous\", &None)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n\n  // Test if only all users, authenticated or not, can view all the comments,\n  // and whether the `can_be_deleted` field is correctly set\n  let published_view_comments: Vec<GlobalComment> = page_owner_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  assert_eq!(published_view_comments.len(), 2);\n  assert!(published_view_comments.iter().all(|c| c.can_be_deleted));\n  let published_view_comments: Vec<GlobalComment> = first_user_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  assert_eq!(published_view_comments.len(), 2);\n  assert_eq!(\n    published_view_comments\n      .iter()\n      .map(|c| c.can_be_deleted)\n      .collect_vec(),\n    vec![true, false]\n  );\n  let published_view_comments: Vec<GlobalComment> = guest_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  assert_eq!(published_view_comments.len(), 2);\n  assert!(published_view_comments.iter().all(|c| !c.can_be_deleted));\n\n  // Test if the comments are correctly sorted\n  let comment_creators = published_view_comments\n    .iter()\n    .map(|c| {\n      c.user\n        .as_ref()\n        .map(|u| u.name.clone())\n        .unwrap_or(\"\".to_string())\n    })\n    .collect_vec();\n  assert_eq!(comment_creators, vec![\"User1\", \"PageOwner\"]);\n  let comment_content = published_view_comments\n    .iter()\n    .map(|c| c.content.clone())\n    .collect_vec();\n  assert_eq!(\n    comment_content,\n    vec![first_user_comment_content, page_owner_comment_content]\n  );\n\n  // Test if it's possible to reply to another user's comment\n  let second_user_comment_content = \"comment from second authenticated user\";\n  let (second_user_client, _) = generate_unique_registered_user_client().await;\n  second_user_client\n    .update_user(UpdateUserParams {\n      name: Some(\"User2\".to_string()),\n      password: None,\n      email: None,\n      metadata: None,\n    })\n    .await\n    .unwrap();\n  {\n    let published_view_comments: Vec<GlobalComment> = guest_client\n      .get_published_view_comments(&view_id)\n      .await\n      .unwrap()\n      .comments;\n    assert_eq!(published_view_comments.len(), 2);\n  }\n  // User 2 reply to user 1\n  second_user_client\n    .create_comment_on_published_view(\n      &view_id,\n      second_user_comment_content,\n      &Some(published_view_comments[0].comment_id),\n    )\n    .await\n    .unwrap();\n  {\n    let published_view_comments: Vec<GlobalComment> = guest_client\n      .get_published_view_comments(&view_id)\n      .await\n      .unwrap()\n      .comments;\n    assert_eq!(published_view_comments.len(), 3);\n  }\n  let published_view_comments: Vec<GlobalComment> = guest_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  let comment_creators = published_view_comments\n    .iter()\n    .map(|c| {\n      c.user\n        .as_ref()\n        .map(|u| u.name.clone())\n        .unwrap_or(\"\".to_string())\n    })\n    .collect_vec();\n  assert_eq!(comment_creators, vec![\"User2\", \"User1\", \"PageOwner\"]);\n  assert_eq!(\n    published_view_comments[0].reply_comment_id,\n    Some(published_view_comments[1].comment_id)\n  );\n\n  // Test if only the page owner or the comment creator can delete a comment\n  // User 1 attempt to delete page owner's comment\n  let result = first_user_client\n    .delete_comment_on_published_view(&view_id, &published_view_comments[2].comment_id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::UserUnAuthorized);\n  // User 1 deletes own comment\n  first_user_client\n    .delete_comment_on_published_view(&view_id, &published_view_comments[1].comment_id)\n    .await\n    .unwrap();\n  // Guest client attempt to delete user 2's comment\n  let result = guest_client\n    .delete_comment_on_published_view(&view_id, &published_view_comments[0].comment_id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n  // Verify that the comments are not deleted from the database, only the is_deleted status changes.\n  let published_view_comments: Vec<GlobalComment> = guest_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  assert_eq!(\n    published_view_comments\n      .iter()\n      .map(|c| c.is_deleted)\n      .collect_vec(),\n    vec![false, true, false]\n  );\n  // Verify that the reference id is still preserved\n  assert_eq!(\n    published_view_comments[0].reply_comment_id,\n    Some(published_view_comments[1].comment_id)\n  );\n\n  for comment in &published_view_comments {\n    page_owner_client\n      .delete_comment_on_published_view(&view_id, &comment.comment_id)\n      .await\n      .unwrap();\n  }\n\n  let published_view_comments: Vec<GlobalComment> = guest_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  assert_eq!(published_view_comments.len(), 3);\n  assert!(published_view_comments.iter().all(|c| c.is_deleted));\n  assert!(published_view_comments.iter().all(|c| !c.can_be_deleted));\n}\n\n#[tokio::test]\nasync fn test_excessive_comment_length() {\n  let (client, _) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&client).await;\n  let published_view_namespace = Uuid::new_v4().to_string();\n  client\n    .set_workspace_publish_namespace(&workspace_id, published_view_namespace)\n    .await\n    .unwrap();\n\n  let publish_name = \"published-view\";\n  let view_id = Uuid::new_v4();\n  client\n    .publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id,\n          publish_name: publish_name.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"some_title\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_1\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap();\n\n  let resp = client\n    .create_comment_on_published_view(&view_id, \"a\".repeat(5001).as_str(), &None)\n    .await;\n  assert!(resp.is_err());\n  assert_eq!(resp.unwrap_err().code, ErrorCode::StringLengthLimitReached);\n}\n\n#[tokio::test]\nasync fn test_publish_reactions() {\n  let (page_owner_client, _) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&page_owner_client).await;\n  let published_view_namespace = Uuid::new_v4().to_string();\n  page_owner_client\n    .set_workspace_publish_namespace(&workspace_id, published_view_namespace)\n    .await\n    .unwrap();\n\n  let publish_name = \"published-view\";\n  let view_id = Uuid::new_v4();\n  page_owner_client\n    .publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id,\n          publish_name: publish_name.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"some_title\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_1\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap();\n  page_owner_client\n    .create_comment_on_published_view(&view_id, \"likable comment\", &None)\n    .await\n    .unwrap();\n  // This is to ensure that the second comment creation timestamp is later than the first one\n  sleep(Duration::from_millis(1));\n  page_owner_client\n    .create_comment_on_published_view(&view_id, \"party comment\", &None)\n    .await\n    .unwrap();\n  let mut comments = page_owner_client\n    .get_published_view_comments(&view_id)\n    .await\n    .unwrap()\n    .comments;\n  comments.sort_by_key(|c| c.created_at);\n  // Test if the reactions are created correctly based on view and comment id\n  let likable_comment_id = comments[0].comment_id;\n  let party_comment_id = comments[1].comment_id;\n\n  let like_emoji = \"👍\";\n  let party_emoji = \"🎉\";\n  page_owner_client\n    .create_reaction_on_comment(like_emoji, &view_id, &likable_comment_id)\n    .await\n    .unwrap();\n  let guest_client = localhost_client();\n  let result = guest_client\n    .create_reaction_on_comment(like_emoji, &view_id, &likable_comment_id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n\n  let (user_client, _) = generate_unique_registered_user_client().await;\n  sleep(Duration::from_millis(1));\n  user_client\n    .create_reaction_on_comment(party_emoji, &view_id, &party_comment_id)\n    .await\n    .unwrap();\n  user_client\n    .create_reaction_on_comment(like_emoji, &view_id, &likable_comment_id)\n    .await\n    .unwrap();\n\n  let reactions = guest_client\n    .get_published_view_reactions(&view_id, &None)\n    .await\n    .unwrap()\n    .reactions;\n  assert_eq!(reactions[0].reaction_type, like_emoji);\n  assert_eq!(reactions[1].reaction_type, party_emoji);\n  let reaction_count: HashMap<String, i32> = reactions\n    .iter()\n    .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32))\n    .collect();\n  assert_eq!(reaction_count.len(), 2);\n  assert_eq!(*reaction_count.get(like_emoji).unwrap(), 2);\n  assert_eq!(*reaction_count.get(party_emoji).unwrap(), 1);\n\n  // Test if the reactions are deleted correctly based on view and comment id\n  let result = guest_client\n    .delete_reaction_on_comment(like_emoji, &view_id, &likable_comment_id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n  user_client\n    .delete_reaction_on_comment(like_emoji, &view_id, &likable_comment_id)\n    .await\n    .unwrap();\n\n  let reactions = guest_client\n    .get_published_view_reactions(&view_id, &None)\n    .await\n    .unwrap()\n    .reactions;\n  let reaction_count: HashMap<String, i32> = reactions\n    .iter()\n    .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32))\n    .collect();\n  assert_eq!(reaction_count.len(), 2);\n  assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1);\n  assert_eq!(*reaction_count.get(party_emoji).unwrap(), 1);\n\n  // Test if we can filter the reactions by comment id\n  let reactions = guest_client\n    .get_published_view_reactions(&view_id, &Some(likable_comment_id))\n    .await\n    .unwrap()\n    .reactions;\n  let reaction_count: HashMap<String, i32> = reactions\n    .iter()\n    .map(|r| (r.reaction_type.clone(), r.react_users.len() as i32))\n    .collect();\n  assert_eq!(reaction_count.len(), 1);\n  assert_eq!(*reaction_count.get(like_emoji).unwrap(), 1);\n}\n\n#[tokio::test]\nasync fn test_publish_load_test() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&c).await;\n  let my_namespace = Uuid::new_v4().to_string();\n  c.set_workspace_publish_namespace(&workspace_id, my_namespace)\n    .await\n    .unwrap();\n\n  {\n    // cannot publish nothing\n    let err = c\n      .publish_collabs::<(), &[u8]>(&workspace_id, vec![])\n      .await\n      .unwrap_err();\n    assert_eq!(err.code, ErrorCode::InvalidRequest);\n  }\n\n  // publish 1000 collabs\n  let collabs: Vec<PublishCollabItem<MyCustomMetadata, Vec<u8>>> = (0..1000)\n    .map(|i| PublishCollabItem {\n      meta: PublishCollabMetadata {\n        view_id: Uuid::new_v4(),\n        publish_name: format!(\"publish-name-{}\", i),\n        metadata: MyCustomMetadata {\n          title: format!(\"title_{}\", i),\n        },\n      },\n      data: vec![0; 100_000],\n      comments_enabled: true,\n      duplicate_enabled: true,\n    })\n    .collect();\n\n  c.publish_collabs(&workspace_id, collabs).await.unwrap();\n}\n\nasync fn get_first_workspace(c: &client_api::Client) -> Uuid {\n  c.get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id\n}\n\n#[tokio::test]\nasync fn workspace_member_publish_unpublish() {\n  let client_1 = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = client_1.workspace_id().await;\n  let client_2 = TestClient::new_user_without_ws_conn().await;\n  client_1\n    .invite_and_accepted_workspace_member(&workspace_id, &client_2, AFRole::Member)\n    .await\n    .unwrap();\n\n  let view_id = Uuid::new_v4();\n  // member can publish without owner setting namespace\n  client_2\n    .api_client\n    .publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id,\n          publish_name: \"publish-name-1\".to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_1\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_1\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap();\n\n  client_2\n    .api_client\n    .unpublish_collabs(&workspace_id, &[view_id])\n    .await\n    .unwrap();\n}\n\n#[derive(Debug, Serialize, Deserialize)]\nstruct MyCustomMetadata {\n  title: String,\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_references() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  let doc_2_view_id = Uuid::new_v4();\n  let doc_1_view_id: Uuid = \"e8c4f99a-50ea-4758-bca0-afa7df5c2434\".parse().unwrap();\n  let grid_1_view_id: Uuid = \"8e062f61-d7ae-4f4b-869c-f44c43149399\".parse().unwrap();\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![\n        (\n          // doc2 contains a reference to doc1\n          doc_2_view_id,\n          published_data::DOC_2_META,\n          published_data::DOC_2_DOC_STATE_HEX,\n        ),\n        (\n          // doc_1_view_id needs to be fixed because doc_2 references it\n          doc_1_view_id,\n          published_data::DOC_1_META,\n          published_data::DOC_1_DOC_STATE_HEX,\n        ),\n        (\n          // doc1 contains @reference database to grid1 (not inline)\n          grid_1_view_id,\n          published_data::GRID_1_META,\n          published_data::GRID_1_DB_DATA,\n        ),\n      ],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    // duplicate doc2 to workspace2\n    // Result fv should be:\n    // .\n    // ├── Getting Started (existing)\n    // └── doc2\n    //     └── doc1\n    //         └── grid1\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        doc_2_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    let doc_2_fv = fv.children[0]\n      .children\n      .iter()\n      .find(|v| v.name == \"doc2\")\n      .unwrap()\n      .clone();\n    assert_ne!(doc_2_fv.view_id, doc_1_view_id);\n\n    let doc_1_fv = doc_2_fv\n      .children\n      .into_iter()\n      .find(|v| v.name == \"doc1\")\n      .unwrap();\n    assert_ne!(doc_1_fv.view_id, doc_1_view_id);\n\n    let grid_1_fv = doc_1_fv\n      .children\n      .into_iter()\n      .find(|v| v.name == \"grid1\")\n      .unwrap();\n    assert_ne!(grid_1_fv.view_id, grid_1_view_id);\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_doc_inline_database() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  // doc3 contains inline database to a view in grid1 (view of grid1)\n  let doc_3_view_id = Uuid::new_v4();\n\n  // view of grid1\n  let view_of_grid_1_view_id: Uuid = \"d8589e98-88fc-42e4-888c-b03338bf22bb\".parse().unwrap();\n  let view_of_grid_1_db_data = hex::decode(published_data::VIEW_OF_GRID_1_DB_DATA).unwrap();\n  let (pub_db_id, pub_row_ids) = get_database_id_and_row_ids(\n    &view_of_grid_1_db_data,\n    client_1.client_id(&workspace_id).await,\n  );\n\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![\n        (\n          doc_3_view_id,\n          published_data::DOC_3_META,\n          published_data::DOC_3_DOC_STATE_HEX,\n        ),\n        (\n          view_of_grid_1_view_id,\n          published_data::VIEW_OF_GRID_1_META,\n          published_data::VIEW_OF_GRID_1_DB_DATA,\n        ),\n      ],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let mut client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n\n    // Open workspace to trigger group creation\n    client_2\n      .open_collab(workspace_id_2, workspace_id_2, CollabType::Folder)\n      .await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    // duplicate doc3 to workspace2\n    // Result fv should be:\n    // .\n    // ├── Getting Started (existing)\n    // └── doc3\n    //     └── grid1\n    //         └── View of grid1\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        doc_3_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    {\n      let fv = client_2\n        .api_client\n        .get_workspace_folder(&workspace_id_2, Some(5), None)\n        .await\n        .unwrap();\n      let doc_3_fv = fv.children[0]\n        .children\n        .iter()\n        .find(|v| v.name == \"doc3\")\n        .unwrap()\n        .clone();\n      let grid1_fv = doc_3_fv\n        .children\n        .into_iter()\n        .find(|v| v.name == \"grid1\")\n        .unwrap();\n      let _view_of_grid1_fv = grid1_fv\n        .children\n        .into_iter()\n        .find(|v| v.name == \"View of grid1\")\n        .unwrap();\n    }\n\n    let collab_resp = client_2\n      .get_collab(workspace_id_2, workspace_id_2, CollabType::Folder)\n      .await\n      .unwrap();\n\n    let folder = Folder::from_collab_doc_state(\n      CollabOrigin::Server,\n      collab_resp.encode_collab.into(),\n      &workspace_id_2.to_string(),\n      default_client_id(),\n    )\n    .unwrap();\n\n    let folder_view = collab_folder_to_folder_view(\n      workspace_id_2,\n      &workspace_id_2,\n      &folder,\n      5,\n      &HashSet::default(),\n      client_2.uid().await,\n    )\n    .unwrap();\n    let doc_3_fv = folder_view.children[0]\n      .children\n      .iter()\n      .find(|v| v.name == \"doc3\")\n      .unwrap()\n      .clone();\n    assert_ne!(doc_3_fv.view_id, doc_3_view_id);\n\n    let grid1_fv = doc_3_fv\n      .children\n      .into_iter()\n      .find(|v| v.name == \"grid1\")\n      .unwrap();\n    assert_ne!(grid1_fv.view_id, view_of_grid_1_view_id);\n\n    let view_of_grid1_fv = grid1_fv\n      .children\n      .into_iter()\n      .find(|v| v.name == \"View of grid1\")\n      .unwrap();\n    assert_ne!(view_of_grid1_fv.view_id, view_of_grid_1_view_id);\n\n    {\n      // check that database_id is different\n      let ws_db_collab = client_2.get_workspace_database_collab(workspace_id_2).await;\n      let ws_db_body = WorkspaceDatabase::open(ws_db_collab).unwrap();\n      let dup_grid1_db_id: Uuid = ws_db_body\n        .get_all_database_meta()\n        .into_iter()\n        .find(|db_meta| {\n          db_meta\n            .linked_views\n            .contains(&view_of_grid1_fv.view_id.to_string())\n        })\n        .unwrap()\n        .database_id\n        .parse()\n        .unwrap();\n      let db_collab = client_2\n        .get_collab_to_collab(workspace_id_2, dup_grid1_db_id, CollabType::Database)\n        .await\n        .unwrap();\n      let dup_db_id: Uuid = DatabaseBody::database_id_from_collab(&db_collab)\n        .unwrap()\n        .parse()\n        .unwrap();\n      assert_ne!(dup_db_id, pub_db_id);\n\n      let txn = db_collab.transact();\n      let view_map = {\n        let map_ref = db_collab\n          .data\n          .get_with_path(&txn, [\"database\", \"views\"])\n          .unwrap();\n        DatabaseViews::new(CollabOrigin::Empty, map_ref, None)\n      };\n\n      for db_view in view_map.get_all_views(&txn) {\n        let database_id: Uuid = db_view.database_id.parse().unwrap();\n        assert_eq!(database_id, dup_db_id);\n        for row_order in db_view.row_orders {\n          let row_order_id: Uuid = row_order.id.parse().unwrap();\n          assert!(\n            !pub_row_ids.contains(&row_order_id),\n            \"published row id is same as duplicated row id\"\n          );\n        }\n      }\n    }\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_db_embedded_in_doc() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  // embedded doc with db\n  // database is created in the doc, not linked from a separate view\n  let doc_with_embedded_db_view_id: Uuid = Uuid::new_v4();\n\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![\n        (\n          doc_with_embedded_db_view_id,\n          published_data::DOC_WITH_EMBEDDED_DB_META,\n          published_data::DOC_WITH_EMBEDDED_DB_HEX,\n        ),\n        (\n          // user will also need to publish the database (even though it is embedded)\n          // uuid must be fixed because it is referenced in the doc\n          \"bb221175-14da-4a05-a09d-595e42d2350f\".parse().unwrap(),\n          published_data::EMBEDDED_DB_META,\n          published_data::EMBEDDED_DB_HEX,\n        ),\n      ],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let mut client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n\n    // Open workspace to trigger group creation\n    client_2\n      .open_collab(workspace_id_2, workspace_id_2, CollabType::Folder)\n      .await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    // duplicate doc_with_embedded_db to workspace2\n    // Result fv should be:\n    // .\n    // ├── Getting Started (existing)\n    // └── db_with_embedded_db (inside should contain the database)\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        doc_with_embedded_db_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    {\n      let fv = client_2\n        .api_client\n        .get_workspace_folder(&workspace_id_2, Some(5), None)\n        .await\n        .unwrap();\n      let doc_with_embedded_db = fv.children[0]\n        .children\n        .iter()\n        .find(|v| v.name == \"docwithembeddeddb\")\n        .unwrap()\n        .clone();\n      let doc_collab = client_2\n        .get_collab_to_collab(\n          workspace_id_2,\n          doc_with_embedded_db.view_id,\n          CollabType::Folder,\n        )\n        .await\n        .unwrap();\n      let doc = Document::open(doc_collab).unwrap();\n      let doc_data = doc.get_document_data().unwrap();\n      let grid = doc_data\n        .blocks\n        .iter()\n        .find(|(_k, b)| b.ty == \"grid\")\n        .unwrap()\n        .1;\n\n      // because it is embedded, the database parent's id is the view id of the doc\n      let parent_id: Uuid = grid\n        .data\n        .get(\"parent_id\")\n        .unwrap()\n        .as_str()\n        .unwrap()\n        .parse()\n        .unwrap();\n      assert_ne!(parent_id, doc_with_embedded_db.view_id.clone());\n    }\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_db_with_relation() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  // database with relation column to another database\n  let db_with_rel_col_view_id: Uuid = Uuid::new_v4();\n\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![\n        (\n          db_with_rel_col_view_id,\n          published_data::DB_WITH_REL_COL_META,\n          published_data::DB_WITH_REL_COL_HEX,\n        ),\n        (\n          // related database\n          // uuid must be fixed because it is related to the db_with_rel_col\n          \"5fc669fa-8867-4f6d-98f1-ce387597eabd\".parse().unwrap(),\n          published_data::RELATED_DB_META,\n          published_data::RELATED_DB_HEX,\n        ),\n      ],\n      true,\n      true,\n    )\n    .await;\n\n  let db_with_row_doc_view_id: Uuid = Uuid::new_v4();\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![(\n        db_with_row_doc_view_id,\n        published_data::DB_ROW_WITH_DOC_META,\n        published_data::DB_ROW_WITH_DOC_HEX,\n      )],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let mut client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    // duplicate db_with_rel_col to workspace2\n    // Result fv should be:\n    // .\n    // ├── Getting Started (existing)\n    // ├── db_with_rel_col\n    // └── related-db\n    // related-db cannot be child of db_with_rel_col because they dont share the same field\n    // and are 2 different databases, so we just put them in the root (dest_id)\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        db_with_rel_col_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    {\n      let fv = client_2\n        .api_client\n        .get_workspace_folder(&workspace_id_2, Some(5), None)\n        .await\n        .unwrap();\n      let db_with_rel_col = fv\n        .children[0].children\n        .iter()\n        .find(|v| v.name == \"grid3\") // db_with_rel_col\n        .unwrap();\n      let related_db = fv\n        .children[0].children\n        .iter()\n        .find(|v| v.name == \"grid2\") // related-db\n        .unwrap();\n      let db_with_rel_col_collab = client_2\n        .get_db_collab_from_view(workspace_id_2, &db_with_rel_col.view_id)\n        .await;\n      let related_db_collab = client_2\n        .get_db_collab_from_view(workspace_id_2, &related_db.view_id)\n        .await;\n\n      let related_db_id: String = related_db_collab\n        .data\n        .get_with_path(&related_db_collab.transact(), [\"database\", \"id\"])\n        .unwrap();\n\n      let rel_col_db_body = DatabaseBody::from_collab(\n        &db_with_rel_col_collab,\n        Arc::new(NoPersistenceDatabaseCollabService::new(\n          client_2.client_id(&workspace_id_2).await,\n        )),\n        None,\n      )\n      .unwrap();\n      let txn = db_with_rel_col_collab.transact();\n      let all_fields = rel_col_db_body.fields.get_all_fields(&txn);\n      all_fields\n        .iter()\n        .map(|f| &f.type_options)\n        .flat_map(|t| t.iter())\n        .filter(|(k, _v)| **k == FieldType::Relation.type_id())\n        .map(|(_k, v)| v)\n        .flat_map(|v| v.iter())\n        .for_each(|(_k, db_id)| {\n          assert_eq!(db_id.to_string(), related_db_id);\n        });\n    }\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_db_row_with_doc() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  let db_with_row_doc_view_id: Uuid = Uuid::new_v4();\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![(\n        db_with_row_doc_view_id,\n        published_data::DB_ROW_WITH_DOC_META,\n        published_data::DB_ROW_WITH_DOC_HEX,\n      )],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let mut client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    // duplicate db_with_row_doc to workspace2\n    // Result fv should be:\n    // .\n    // ├── Getting Started (existing)\n    // └── db_with_row_doc\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        db_with_row_doc_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    {\n      let fv = client_2\n        .api_client\n        .get_workspace_folder(&workspace_id_2, Some(5), None)\n        .await\n        .unwrap();\n      let db_with_row_doc = fv\n        .children[0].children\n        .iter()\n        .find(|v| v.name == \"db_with_row_doc\") // db_w ith_rel_col\n        .unwrap();\n\n      let db_collab = client_2\n        .get_db_collab_from_view(workspace_id_2, &db_with_row_doc.view_id)\n        .await;\n\n      let db_body = DatabaseBody::from_collab(\n        &db_collab,\n        Arc::new(NoPersistenceDatabaseCollabService::new(\n          client_2.client_id(&workspace_id_2).await,\n        )),\n        None,\n      )\n      .unwrap();\n\n      // check that doc exists and can be fetched\n      let first_row_id: Uuid = db_body.views.get_all_views(&db_collab.transact())[0].row_orders[0]\n        .id\n        .parse()\n        .unwrap();\n      let row_collab = client_2\n        .get_collab_to_collab(workspace_id_2, first_row_id, CollabType::DatabaseRow)\n        .await\n        .unwrap();\n      let row_detail = RowDetail::from_collab(&row_collab).unwrap();\n      assert!(!row_detail.meta.is_document_empty);\n      let doc_id: Uuid = row_detail.document_id.parse().unwrap();\n      let _doc_collab = client_2\n        .get_collab_to_collab(workspace_id_2, doc_id, CollabType::Document)\n        .await;\n      let folder_collab = client_2\n        .get_collab_to_collab(workspace_id_2, workspace_id_2, CollabType::Folder)\n        .await\n        .unwrap();\n      let folder = Folder::open(folder_collab, None).unwrap();\n      let doc_view = folder\n        .get_view(&doc_id.to_string(), client_2.uid().await)\n        .unwrap();\n      assert_eq!(doc_view.id, doc_view.parent_view_id);\n    }\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_db_rel_self() {\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  let db_rel_self_view_id: Uuid = \"18d72589-80d7-4041-9342-5d572facb7c9\".parse().unwrap();\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![(\n        db_rel_self_view_id,\n        published_data::DB_REL_SELF_META,\n        published_data::DB_REL_SELF_HEX,\n      )],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let mut client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        db_rel_self_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n    println!(\"{:#?}\", fv);\n\n    let db_rel_self = fv.children[0]\n      .children\n      .iter()\n      .find(|v| v.name == \"self_ref_db\")\n      .unwrap();\n\n    let db_rel_self_collab = client_2\n      .get_db_collab_from_view(workspace_id_2, &db_rel_self.view_id)\n      .await;\n    let txn = db_rel_self_collab.transact();\n    let db_rel_self_body = DatabaseBody::from_collab(\n      &db_rel_self_collab,\n      Arc::new(NoPersistenceDatabaseCollabService::new(\n        client_2.client_id(&workspace_id_2).await,\n      )),\n      None,\n    )\n    .unwrap();\n    let database_id = db_rel_self_body.get_database_id(&txn);\n    let all_fields = db_rel_self_body.fields.get_all_fields(&txn);\n    let rel_fields = all_fields\n      .iter()\n      .map(|f| &f.type_options)\n      .flat_map(|t| t.iter())\n      .filter(|(k, _v)| **k == FieldType::Relation.type_id())\n      .map(|(_k, v)| v)\n      .flat_map(|v| v.iter())\n      .collect::<Vec<_>>();\n    assert_eq!(rel_fields.len(), 1);\n    assert_eq!(rel_fields[0].1.to_string(), database_id);\n  }\n}\n\n#[tokio::test]\nasync fn duplicate_to_workspace_inline_db_doc_with_relation() {\n  // scenario:\n  // client_1 publish doc4\n  // doc4 has inline database grid3\n  // grid3 has relation to grid2\n  // grid2 has relation to grid3\n  // .\n  // ├── grid2\n  // └┬─ doc4\n  //  └── grid3\n\n  let client_1 = TestClient::new_user().await;\n  let workspace_id = client_1.workspace_id().await;\n\n  // database with a row referencing itself\n  // uuid must be fixed because there is inline block that have parent_id that references this\n  let doc_4_view_id: Uuid = \"bb429fb8-3bb8-4f8c-99d8-0d8c48047efc\".parse().unwrap();\n  client_1\n    .publish_collabs(\n      &workspace_id,\n      vec![\n        (\n          doc_4_view_id,\n          published_data::DOC_4_META,\n          published_data::DOC_4_DOC_STATE_HEX,\n        ),\n        (\n          \"5ecf6aa1-d4d6-47e4-af0f-3a7a9fd8299d\".parse().unwrap(),\n          published_data::GRID_3_META,\n          published_data::GRID_3_HEX,\n        ),\n        (\n          \"cfdd03b2-b539-4f1d-84c3-3c0424bbd345\".parse().unwrap(),\n          published_data::GRID_2_META,\n          published_data::GRID_2_HEX,\n        ),\n      ],\n      true,\n      true,\n    )\n    .await;\n\n  {\n    let client_2 = TestClient::new_user().await;\n    let workspace_id_2 = client_2.workspace_id().await;\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    client_2\n      .duplicate_published_to_workspace(\n        workspace_id_2,\n        doc_4_view_id,\n        fv.children[0].view_id, // use the first space found in the workspace\n      )\n      .await;\n\n    let fv = client_2\n      .api_client\n      .get_workspace_folder(&workspace_id_2, Some(5), None)\n      .await\n      .unwrap();\n\n    let doc_4_fv = fv.children[0]\n      .children\n      .iter()\n      .find(|v| v.name == \"doc4\")\n      .unwrap();\n    let _ = doc_4_fv\n      .children\n      .iter()\n      .find(|v| v.name == \"grid3\")\n      .unwrap();\n    let _ = fv.children[0]\n      .children\n      .iter()\n      .find(|v| v.name == \"grid2\")\n      .unwrap();\n  }\n}\n\nfn get_database_id_and_row_ids(\n  published_db_blob: &[u8],\n  client_id: ClientID,\n) -> (Uuid, HashSet<Uuid>) {\n  let pub_db_data = serde_json::from_slice::<PublishDatabaseData>(published_db_blob).unwrap();\n  let db_collab =\n    collab_from_doc_state(pub_db_data.database_collab, &Uuid::default(), client_id).unwrap();\n  let pub_db_id = DatabaseBody::database_id_from_collab(&db_collab)\n    .unwrap()\n    .parse::<Uuid>()\n    .unwrap();\n  let row_ids: HashSet<_> = pub_db_data.database_row_collabs.into_keys().collect();\n  (pub_db_id, row_ids)\n}\n\n#[tokio::test]\nasync fn test_republish_doc() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&c).await;\n  let my_namespace = Uuid::new_v4().to_string();\n  c.set_workspace_publish_namespace(&workspace_id, my_namespace.clone())\n    .await\n    .unwrap();\n\n  let publish_name = \"my-publish-name\";\n  let view_id = Uuid::new_v4();\n\n  // User publishes 1 doc\n  c.publish_collabs::<MyCustomMetadata, &[u8]>(\n    &workspace_id,\n    vec![PublishCollabItem {\n      meta: PublishCollabMetadata {\n        view_id,\n        publish_name: publish_name.to_string(),\n        metadata: MyCustomMetadata {\n          title: \"my_title_1\".to_string(),\n        },\n      },\n      data: \"yrs_encoded_data_1\".as_bytes(),\n      comments_enabled: true,\n      duplicate_enabled: true,\n    }],\n  )\n  .await\n  .unwrap();\n\n  {\n    // Check that the doc is published with correct publish name\n    let publish_info = c.get_published_collab_info(&view_id).await.unwrap();\n    assert_eq!(\n      publish_info.publish_name, publish_name,\n      \"{:?}\",\n      publish_info\n    );\n  }\n\n  // user unpublishes the doc\n  c.unpublish_collabs(&workspace_id, &[view_id])\n    .await\n    .unwrap();\n\n  {\n    // Check that the doc is unpublished\n    let publish_info = c.get_published_collab_info(&view_id).await.unwrap();\n    assert!(\n      publish_info.unpublished_timestamp.is_some(),\n      \"{:?}\",\n      publish_info\n    );\n    assert_eq!(\n      publish_info.publish_name, publish_name,\n      \"{:?}\",\n      publish_info\n    );\n  }\n\n  {\n    // User publish another doc with different id but same publish name\n    let view_id_2 = Uuid::new_v4();\n    c.publish_collabs::<MyCustomMetadata, &[u8]>(\n      &workspace_id,\n      vec![PublishCollabItem {\n        meta: PublishCollabMetadata {\n          view_id: view_id_2,\n          publish_name: publish_name.to_string(),\n          metadata: MyCustomMetadata {\n            title: \"my_title_2\".to_string(),\n          },\n        },\n        data: \"yrs_encoded_data_2\".as_bytes(),\n        comments_enabled: true,\n        duplicate_enabled: true,\n      }],\n    )\n    .await\n    .unwrap();\n\n    let publish_info = c.get_published_collab_info(&view_id_2).await.unwrap();\n    assert_eq!(\n      publish_info.publish_name, publish_name,\n      \"{:?}\",\n      publish_info\n    );\n  }\n\n  {\n    // When fetching original document, it should return not found\n    // since the binded publish name is already used by another document\n    let err = c.get_published_collab_info(&view_id).await.unwrap_err();\n    assert_eq!(err.code, ErrorCode::RecordNotFound, \"{:?}\", err);\n  }\n}\n\n#[tokio::test]\nasync fn test_republish_patch() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&c).await;\n  let my_namespace = Uuid::new_v4().to_string();\n  c.set_workspace_publish_namespace(&workspace_id, my_namespace.clone())\n    .await\n    .unwrap();\n\n  let publish_name = \"my-publish-name\";\n  let view_id = Uuid::new_v4();\n\n  // User publishes 1 doc\n  c.publish_collabs::<MyCustomMetadata, &[u8]>(\n    &workspace_id,\n    vec![PublishCollabItem {\n      meta: PublishCollabMetadata {\n        view_id,\n        publish_name: publish_name.to_string(),\n        metadata: MyCustomMetadata {\n          title: \"my_title_1\".to_string(),\n        },\n      },\n      data: \"yrs_encoded_data_1\".as_bytes(),\n      comments_enabled: true,\n      duplicate_enabled: true,\n    }],\n  )\n  .await\n  .unwrap();\n\n  // user unpublishes the doc\n  c.unpublish_collabs(&workspace_id, &[view_id])\n    .await\n    .unwrap();\n\n  // User publish another doc\n  let publish_name_2 = \"my-publish-name-2\";\n  let view_id_2 = Uuid::new_v4();\n  c.publish_collabs::<MyCustomMetadata, &[u8]>(\n    &workspace_id,\n    vec![PublishCollabItem {\n      meta: PublishCollabMetadata {\n        view_id: view_id_2,\n        publish_name: publish_name_2.to_string(),\n        metadata: MyCustomMetadata {\n          title: \"my_title_1\".to_string(),\n        },\n      },\n      data: \"yrs_encoded_data_1\".as_bytes(),\n      comments_enabled: true,\n      duplicate_enabled: true,\n    }],\n  )\n  .await\n  .unwrap();\n\n  // User change the publish name of the document to publish_name\n  // which should be allowed since the original document is already unpublished\n  c.patch_published_collabs(\n    &workspace_id,\n    &[PatchPublishedCollab {\n      view_id: view_id_2,\n      publish_name: Some(publish_name.to_string()),\n      comments_enabled: None,\n      duplicate_enabled: None,\n    }],\n  )\n  .await\n  .unwrap();\n}\n"
  },
  {
    "path": "tests/workspace/published_data.rs",
    "content": "pub const DOC_1_META: &str = r#\"\n  {\n    \"view\": {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🍚\"\n      },\n      \"name\": \"doc1\",\n      \"extra\": \"{\\\"cover\\\":{\\\"type\\\":\\\"none\\\",\\\"value\\\":\\\"\\\"}}\",\n      \"layout\": 0,\n      \"view_id\": \"e8c4f99a-50ea-4758-bca0-afa7df5c2434\",\n      \"created_at\": 1724143304,\n      \"created_by\": 311828434584080400,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080400,\n      \"last_edited_time\": 1724146846\n    },\n    \"child_views\": [],\n    \"ancestor_views\": [\n      {\n        \"icon\": null,\n        \"name\": \"Workspace\",\n        \"extra\": null,\n        \"layout\": 0,\n        \"view_id\": \"043832c3-c9c4-40e8-ae02-e25677ef344f\",\n        \"created_at\": 1724143277,\n        \"created_by\": 311828434584080400,\n        \"child_views\": null,\n        \"last_edited_by\": 311828434584080400,\n        \"last_edited_time\": 1724143277\n      },\n      {\n        \"icon\": null,\n        \"name\": \"Shared\",\n        \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"space_icon_1\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1724143277323}\",\n        \"layout\": 0,\n        \"view_id\": \"52adbe8e-57c1-43e9-8ef0-e7d49618aa27\",\n        \"created_at\": 1724146819,\n        \"created_by\": 311828434584080400,\n        \"child_views\": null,\n        \"last_edited_by\": 311828434584080400,\n        \"last_edited_time\": 1724146819\n      },\n      {\n        \"icon\": {\n          \"ty\": 0,\n          \"value\": \"🍚\"\n        },\n        \"name\": \"doc1\",\n        \"extra\": \"{\\\"cover\\\":{\\\"type\\\":\\\"none\\\",\\\"value\\\":\\\"\\\"}}\",\n        \"layout\": 0,\n        \"view_id\": \"e8c4f99a-50ea-4758-bca0-afa7df5c2434\",\n        \"created_at\": 1724143304,\n        \"created_by\": 311828434584080400,\n        \"child_views\": null,\n        \"last_edited_by\": 311828434584080400,\n        \"last_edited_time\": 1724146846\n      }\n    ]\n  }\n\"#;\npub const DOC_1_DOC_STATE_HEX: &str = \"050faafac5b509002700edea9f92040406367975544b5f022700edea9f920401067042476d6f41012800aafac5b509010269640177067042476d6f412800aafac5b509010274790177097061726167726170682800aafac5b5090106706172656e7401772434616236646431662d353866342d353033632d613533642d3465383038336133373263662800aafac5b50901086368696c6472656e017706623030782d4d2800aafac5b5090104646174610177027b7d2800aafac5b509010b65787465726e616c5f6964017706367975544b5f2800aafac5b509010d65787465726e616c5f74797065017704746578742700edea9f92040306623030782d4d0088edea9f9204180177067042476d6f410100aafac5b509000a86aafac5b50914076d656e74696f6e407b2274797065223a2270616765222c22706167655f6964223a2238653036326636312d643761652d346634622d383639632d663434633433313439333939227d84aafac5b50915012486aafac5b50916076d656e74696f6e046e756c6c03b1dfade005000400edea9f92041905646f63312081b1dfade005040284b1dfade005060673747566667302d380919c04002101046d6574610c6c6173745f73796e635f617430a8d380919c042f017d9ebfa3ec0c1aedea9f9204002701046461746108646f63756d656e74012700edea9f92040006626c6f636b73012700edea9f920400046d657461012700edea9f9204020c6368696c6472656e5f6d6170012700edea9f92040208746578745f6d6170012800edea9f92040007706167655f696401772434616236646431662d353866342d353033632d613533642d3465383038336133373263662700edea9f9204012434616236646431662d353866342d353033632d613533642d346538303833613337326366012800edea9f92040602696401772434616236646431662d353866342d353033632d613533642d3465383038336133373263662800edea9f920406027479017704706167652800edea9f92040606706172656e740177002800edea9f920406086368696c6472656e01772434616236646431662d353866342d353033632d613533642d3465383038336133373263662800edea9f92040604646174610177027b7d2800edea9f9204060b65787465726e616c5f6964017e2800edea9f9204060d65787465726e616c5f74797065017e2700edea9f9204032434616236646431662d353866342d353033632d613533642d346538303833613337326366002700edea9f9204010a65676e6d5f7341724f33012800edea9f92040f02696401770a65676e6d5f7341724f332800edea9f92040f0274790177097061726167726170682800edea9f92040f06706172656e7401772434616236646431662d353866342d353033632d613533642d3465383038336133373263662800edea9f92040f086368696c6472656e01770a666639562d2d384c77722800edea9f92040f04646174610177027b7d2800edea9f92040f0b65787465726e616c5f696401770a6d59565a5978314579682800edea9f92040f0d65787465726e616c5f74797065017704746578742700edea9f9204030a666639562d2d384c7772000800edea9f92040e01770a65676e6d5f7341724f332700edea9f9204040a6d59565a5978314579680201ed9a8cf603002101046d6574610c6c6173745f73796e635f61742804b1dfade005010502aafac5b509010b0ad380919c04010030ed9a8cf603010028\";\n\npub const DOC_2_META: &str = r#\"\n{\n \"view\": {\n   \"icon\": {\n     \"ty\": 0,\n     \"value\": \"🇧🇼\"\n   },\n   \"name\": \"doc2\",\n   \"extra\": \"{\\\"cover\\\":{\\\"type\\\":\\\"none\\\",\\\"value\\\":\\\"\\\"}}\",\n   \"layout\": 0,\n   \"view_id\": \"9234eaf0-30ee-4edf-b503-e76869d584e5\",\n   \"created_at\": 1724143313,\n   \"created_by\": 311828434584080400,\n   \"child_views\": null,\n   \"last_edited_by\": 311828434584080400,\n   \"last_edited_time\": 1724143963\n },\n \"child_views\": [],\n \"ancestor_views\": [\n   {\n     \"icon\": null,\n     \"name\": \"Workspace\",\n     \"extra\": null,\n     \"layout\": 0,\n     \"view_id\": \"043832c3-c9c4-40e8-ae02-e25677ef344f\",\n     \"created_at\": 1724143277,\n     \"created_by\": 311828434584080400,\n     \"child_views\": null,\n     \"last_edited_by\": 311828434584080400,\n     \"last_edited_time\": 1724143277\n   },\n   {\n     \"icon\": null,\n     \"name\": \"Shared\",\n     \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"space_icon_1\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1724143277323}\",\n     \"layout\": 0,\n     \"view_id\": \"52adbe8e-57c1-43e9-8ef0-e7d49618aa27\",\n     \"created_at\": 1724143313,\n     \"created_by\": 311828434584080400,\n     \"child_views\": null,\n     \"last_edited_by\": 311828434584080400,\n     \"last_edited_time\": 1724143313\n   },\n   {\n     \"icon\": {\n       \"ty\": 0,\n       \"value\": \"🇧🇼\"\n     },\n     \"name\": \"doc2\",\n     \"extra\": \"{\\\"cover\\\":{\\\"type\\\":\\\"none\\\",\\\"value\\\":\\\"\\\"}}\",\n     \"layout\": 0,\n     \"view_id\": \"9234eaf0-30ee-4edf-b503-e76869d584e5\",\n     \"created_at\": 1724143313,\n     \"created_by\": 311828434584080400,\n     \"child_views\": null,\n     \"last_edited_by\": 311828434584080400,\n     \"last_edited_time\": 1724143963\n   }\n ]\n}\n\"#;\npub const DOC_2_DOC_STATE_HEX: &str = \"03048f99d3810d00010096d0a384091904868f99d3810d03076d656e74696f6e407b2274797065223a2270616765222c22706167655f6964223a2265386334663939612d353065612d343735382d626361302d616661376466356332343334227d848f99d3810d040124868f99d3810d05076d656e74696f6e046e756c6c028a99c3a80b002101046d6574610c6c6173745f73796e635f617427a88a99c3a80b26017d91e5a2ec0c1a96d0a38409002701046461746108646f63756d656e7401270096d0a384090006626c6f636b7301270096d0a3840900046d65746101270096d0a38409020c6368696c6472656e5f6d617001270096d0a384090208746578745f6d617001280096d0a384090007706167655f696401772439663235353130332d656663372d353136612d613637352d383330633561313931393931270096d0a38409010a356d593767344e70477601280096d0a384090602696401770a356d593767344e704776280096d0a3840906027479017709706172616772617068280096d0a384090606706172656e7401772439663235353130332d656663372d353136612d613637352d383330633561313931393931280096d0a3840906086368696c6472656e01770a36413051464d6a356842280096d0a384090604646174610177027b7d280096d0a38409060b65787465726e616c5f696401770a4946763557524e774138280096d0a38409060d65787465726e616c5f7479706501770474657874270096d0a38409030a36413051464d6a35684200270096d0a38409012439663235353130332d656663372d353136612d613637352d38333063356131393139393101280096d0a384090f02696401772439663235353130332d656663372d353136612d613637352d383330633561313931393931280096d0a384090f02747901770470616765280096d0a384090f06706172656e74017700280096d0a384090f086368696c6472656e01772439663235353130332d656663372d353136612d613637352d383330633561313931393931280096d0a384090f04646174610177027b7d280096d0a384090f0b65787465726e616c5f6964017e280096d0a384090f0d65787465726e616c5f74797065017e270096d0a38409032439663235353130332d656663372d353136612d613637352d38333063356131393139393100080096d0a384091701770a356d593767344e704776270096d0a38409040a4946763557524e77413802028a99c3a80b0100278f99d3810d010004\";\n\npub const GRID_1_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🍽️\"\n    },\n    \"name\": \"grid1\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"8e062f61-d7ae-4f4b-869c-f44c43149399\",\n    \"created_at\": 1724146952,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1724149734\n  },\n  \"child_views\": [\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🏫\"\n      },\n      \"name\": \"View of grid1\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"d8589e98-88fc-42e4-888c-b03338bf22bb\",\n      \"created_at\": 1724146952,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724149731\n    }\n  ],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"043832c3-c9c4-40e8-ae02-e25677ef344f\",\n      \"created_at\": 1724147455,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724147455\n    },\n    {\n      \"icon\": null,\n      \"name\": \"Shared\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"space_icon_1\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1724143277323}\",\n      \"layout\": 0,\n      \"view_id\": \"52adbe8e-57c1-43e9-8ef0-e7d49618aa27\",\n      \"created_at\": 1724146934,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724146934\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🍽️\"\n      },\n      \"name\": \"grid1\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"8e062f61-d7ae-4f4b-869c-f44c43149399\",\n      \"created_at\": 1724146952,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724149734\n    }\n  ]\n}\n\"#;\npub const GRID_1_DB_DATA: &str = \"7b2264617461626173655f636f6c6c6162223a5b32362c31372c3233312c3139322c3138392c3234312c31352c302c3136312c3235322c3231352c3137372c3136352c31332c312c312c33392c302c3235322c3231352c3137372c3136352c31332c332c33362c3130302c35362c35332c35362c35372c3130312c35372c35362c34352c35362c35362c3130322c39392c34352c35322c35302c3130312c35322c34352c35362c35362c35362c39392c34352c39382c34382c35312c35312c35312c35362c39382c3130322c35302c35302c39382c39382c312c34302c302c3233312c3139322c3138392c3234312c31352c312c322c3130352c3130302c312c3131392c33362c3130302c35362c35332c35362c35372c3130312c35372c35362c34352c35362c35362c3130322c39392c34352c35322c35302c3130312c35322c34352c35362c35362c35362c39392c34352c39382c34382c35312c35312c35312c35362c39382c3130322c35302c35302c39382c39382c34302c302c3233312c3139322c3138392c3234312c31352c312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3233312c3139322c3138392c3234312c31352c312c342c3131302c39372c3130392c3130312c312c3131392c31332c38362c3130352c3130312c3131392c33322c3131312c3130322c33322c3130332c3131342c3130352c3130302c34392c34302c302c3233312c3139322c3138392c3234312c31352c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c302c34302c302c3233312c3139322c3138392c3234312c31352c312c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3233312c3139322c3138392c3234312c31352c312c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3233312c3139322c3138392c3234312c31352c312c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3233312c3139322c3138392c3234312c31352c31332c332c3131382c312c322c3130352c3130302c3131392c362c3131332c37302c3131332c3131302c38362c3130312c3131382c312c322c3130352c3130302c3131392c362c34392c37312c37362c37302c37312c3131332c3131382c312c322c3130352c3130302c3131392c362c3131352c36382c37342c3131362c3132312c37362c33392c302c3233312c3139322c3138392c3234312c31352c312c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3233312c3139322c3138392c3234312c31352c31372c332c3131382c322c322c3130352c3130302c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c312c3133312c3233382c3136322c3139372c31352c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3233322c3233322c3234342c3136322c31352c302c3136312c3231352c3136312c3133362c3233322c31322c302c312c312c3233362c3235352c3137392c3135392c31342c302c3136382c3231312c3139392c3133312c3235302c31302c302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c312c3135352c3232332c3235302c3134382c31342c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c36392c3235322c3231352c3137372c3136352c31332c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3235322c3231352c3137372c3136352c31332c302c322c3130352c3130302c312c33392c302c3235322c3231352c3137372c3136352c31332c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3235322c3231352c3137372c3136352c31332c342c332c3130352c3130352c3130302c312c3131392c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c33392c302c3235322c3231352c3137372c3136352c31332c322c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3235322c3231352c3137372c3136352c31332c362c322c3130352c3130302c312c3131392c362c3131332c37302c3131332c3131302c38362c3130312c34302c302c3235322c3231352c3137372c3136352c31332c362c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3235322c3231352c3137372c3136352c31332c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c362c322c3131362c3132312c312c3132352c302c33392c302c3235322c3231352c3137372c3136352c31332c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c31332c312c34382c312c34302c302c3235322c3231352c3137372c3136352c31332c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3235322c3231352c3137372c3136352c31332c322c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3235322c3231352c3137372c3136352c31332c31362c322c3130352c3130302c312c3131392c362c34392c37312c37362c37302c37312c3131332c34302c302c3235322c3231352c3137372c3136352c31332c31362c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3235322c3231352c3137372c3136352c31332c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3235322c3231352c3137372c3136352c31332c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3235322c3231352c3137372c3136352c31332c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235322c3231352c3137372c3136352c31332c31362c322c3131362c3132312c312c3132352c332c33392c302c3235322c3231352c3137372c3136352c31332c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c32332c312c35312c312c33332c302c3235322c3231352c3137372c3136352c31332c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3235322c3231352c3137372c3136352c31332c322c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3235322c3231352c3137372c3136352c31332c32362c322c3130352c3130302c312c3131392c362c3131352c36382c37342c3131362c3132312c37362c34302c302c3235322c3231352c3137372c3136352c31332c32362c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3235322c3231352c3137372c3136352c31332c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c32362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235322c3231352c3137372c3136352c31332c32362c322c3131362c3132312c312c3132352c352c33392c302c3235322c3231352c3137372c3136352c31332c32362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c33332c312c35332c312c33392c302c3235322c3231352c3137372c3136352c31332c332c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c312c34302c302c3235322c3231352c3137372c3136352c31332c33352c322c3130352c3130302c312c3131392c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c34302c302c3235322c3231352c3137372c3136352c31332c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3235322c3231352c3137372c3136352c31332c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3235322c3231352c3137372c3136352c31332c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33392c302c3235322c3231352c3137372c3136352c31332c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3235322c3231352c3137372c3136352c31332c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3235322c3231352c3137372c3136352c31332c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c34302c302c3235322c3231352c3137372c3136352c31332c34342c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3235322c3231352c3137372c3136352c31332c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c34382c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3235322c3231352c3137372c3136352c31332c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c33392c302c3235322c3231352c3137372c3136352c31332c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235322c3231352c3137372c3136352c31332c35392c332c3131382c312c322c3130352c3130302c3131392c362c3131332c37302c3131332c3131302c38362c3130312c3131382c312c322c3130352c3130302c3131392c362c34392c37312c37362c37302c37312c3131332c3131382c312c322c3130352c3130302c3131392c362c3131352c36382c37342c3131362c3132312c37362c33392c302c3235322c3231352c3137372c3136352c31332c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235322c3231352c3137372c3136352c31332c36332c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c3136312c3235322c3231352c3137372c3136352c31332c32302c312c3136312c3235322c3231352c3137372c3136352c31332c32352c312c3136312c3235322c3231352c3137372c3136352c31332c36372c312c3136312c3235322c3231352c3137372c3136352c31332c36382c312c3136382c3235322c3231352c3137372c3136352c31332c36392c312c3132352c3134382c3134362c3136332c3233362c31322c3136382c3235322c3231352c3137372c3136352c31332c37302c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c35352c35342c39392c3130382c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3132312c3130312c3131302c34352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c38322c3131322c34382c35332c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c312c3234372c3234312c3233392c3233332c31322c302c3136312c3231352c3136312c3133362c3233322c31322c302c312c312c3231352c3136312c3133362c3233322c31322c302c3136312c3135352c3232332c3235302c3134382c31342c302c312c312c3136342c3235352c3133352c3135392c31322c302c3136312c3234372c3234312c3233392c3233332c31322c302c312c312c3136302c3136332c3230342c3230302c31312c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3231302c3133382c3230362c3133342c31312c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3231312c3139392c3133312c3235302c31302c302c3136312c3230322c3136332c3230382c3137302c392c302c312c312c3233352c3136332c3230382c3137302c392c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3230322c3136332c3230382c3137302c392c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3233342c3139382c3234332c3138312c382c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3137362c3234302c3233302c3137392c382c302c3136312c3135352c3232332c3235302c3134382c31342c302c312c312c3132382c3132392c3134392c3231352c372c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3234312c3134312c3232332c3137362c372c302c3136312c3230322c3136332c3230382c3137302c392c302c312c312c3133382c3233312c3232392c3232392c362c302c3136312c3133322c3232352c3134382c3135392c332c302c312c312c3230342c3231322c3235332c3230372c362c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3139352c3232392c3137372c3231322c352c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3133322c3232352c3134382c3135392c332c302c3136312c3235322c3231352c3137372c3136352c31332c312c312c312c3232312c3134392c3135382c3232392c322c302c3136312c3137362c3234302c3233302c3137392c382c302c312c312c3234382c3137332c3230362c3230312c322c302c3136312c3133312c3233382c3136322c3139372c31352c302c312c312c3136362c3230342c3135322c35372c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c322c3132392c3233382c3134322c34392c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c3136392c312c3136382c3132392c3233382c3134322c34392c3136382c312c312c3132352c3136392c3139312c3136332c3233362c31322c32352c3136302c3136332c3230342c3230302c31312c312c302c312c3132392c3233382c3134322c34392c312c302c3136392c312c3132382c3132392c3134392c3231352c372c312c302c312c3133312c3233382c3136322c3139372c31352c312c302c312c3133322c3232352c3134382c3135392c332c312c302c312c3136342c3235352c3133352c3135392c31322c312c302c312c3136362c3230342c3135322c35372c312c302c312c3233312c3139322c3138392c3234312c31352c312c302c312c3233322c3233322c3234342c3136322c31352c312c302c312c3139352c3232392c3137372c3231322c352c312c302c312c3133382c3233312c3232392c3232392c362c312c302c312c3233352c3136332c3230382c3137302c392c312c302c312c3230342c3231322c3235332c3230372c362c312c302c312c3230322c3136332c3230382c3137302c392c312c302c312c3233342c3139382c3234332c3138312c382c312c302c312c3137362c3234302c3233302c3137392c382c312c302c312c3234312c3134312c3232332c3137362c372c312c302c312c3231302c3133382c3230362c3133342c31312c312c302c312c3231312c3139392c3133312c3235302c31302c312c302c312c3231352c3136312c3133362c3233322c31322c312c302c312c3234372c3234312c3233392c3233332c31322c312c302c312c3234382c3137332c3230362c3230312c322c312c302c312c3135352c3232332c3235302c3134382c31342c312c302c312c3235322c3231352c3137372c3136352c31332c342c312c312c32302c312c32352c312c36372c342c3232312c3134392c3135382c3232392c322c312c302c315d2c2264617461626173655f726f775f636f6c6c616273223a7b2239383562326332342d653832362d346137352d613831322d626531393831323931616333223a5b322c322c3134302c3138302c3232362c3232352c382c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c37302c3136382c3134302c3138302c3232362c3232352c382c36392c312c3132352c3136352c3139312c3136332c3233362c31322c32382c3232312c3134382c3233312c3139322c352c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3232312c3134382c3233312c3139322c352c302c322c3130352c3130302c312c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c34302c302c3232312c3134382c3233312c3139322c352c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3232312c3134382c3233312c3139322c352c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3232312c3134382c3233312c3139322c352c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3232312c3134382c3233312c3139322c352c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3232312c3134382c3233312c3139322c352c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3232312c3134382c3233312c3139322c352c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3232312c3134382c3233312c3139322c352c382c312c33392c302c3232312c3134382c3233312c3139322c352c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3232312c3134382c3233312c3139322c352c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134342c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3232312c3134382c3233312c3139322c352c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130382c34302c302c3232312c3134382c3233312c3139322c352c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134342c3134362c3136332c3233362c31322c3136312c3232312c3134382c3233312c3139322c352c31302c312c33392c302c3232312c3134382c3233312c3139322c352c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3232312c3134382c3233312c3139322c352c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134372c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c31372c342c3130302c39372c3131362c39372c312c3131392c342c3132312c3130312c3131302c34352c34302c302c3232312c3134382c3233312c3139322c352c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3232312c3134382c3233312c3139322c352c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134372c3134362c3136332c3233362c31322c3136382c3232312c3134382c3233312c3139322c352c31362c312c3132352c3135302c3134362c3136332c3233362c31322c33392c302c3232312c3134382c3233312c3139322c352c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3232312c3134382c3233312c3139322c352c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3135302c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c352c34302c302c3232312c3134382c3233312c3139322c352c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3232312c3134382c3233312c3139322c352c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3135302c3134362c3136332c3233362c31322c322c3134302c3138302c3232362c3232352c382c312c302c37302c3232312c3134382c3233312c3139322c352c332c382c312c31302c312c31362c315d2c2265333133663030392d316136632d346637342d616363622d313262653236343161613633223a5b322c32382c3134342c3139342c3136382c3230352c31322c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3134342c3139342c3136382c3230352c31322c302c322c3130352c3130302c312c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c34302c302c3134342c3139342c3136382c3230352c31322c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3134342c3139342c3136382c3230352c31322c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3134342c3139342c3136382c3230352c31322c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3134342c3139342c3136382c3230352c31322c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3134342c3139342c3136382c3230352c31322c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3134342c3139342c3136382c3230352c31322c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3134342c3139342c3136382c3230352c31322c382c312c33392c302c3134342c3139342c3136382c3230352c31322c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3134342c3139342c3136382c3230352c31322c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134342c3134362c3136332c3233362c31322c34302c302c3134342c3139342c3136382c3230352c31322c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130342c34302c302c3134342c3139342c3136382c3230352c31322c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3134342c3139342c3136382c3230352c31322c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134342c3134362c3136332c3233362c31322c3136312c3134342c3139342c3136382c3230352c31322c31302c312c33392c302c3134342c3139342c3136382c3230352c31322c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3134342c3139342c3136382c3230352c31322c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134362c3134362c3136332c3233362c31322c34302c302c3134342c3139342c3136382c3230352c31322c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3134342c3139342c3136382c3230352c31322c31372c342c3130302c39372c3131362c39372c312c3131392c342c38322c3131322c34382c35332c34302c302c3134342c3139342c3136382c3230352c31322c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134362c3134362c3136332c3233362c31322c3136382c3134342c3139342c3136382c3230352c31322c31362c312c3132352c3135302c3134362c3136332c3233362c31322c33392c302c3134342c3139342c3136382c3230352c31322c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3134342c3139342c3136382c3230352c31322c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3135302c3134362c3136332c3233362c31322c34302c302c3134342c3139342c3136382c3230352c31322c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c352c34302c302c3134342c3139342c3136382c3230352c31322c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3134342c3139342c3136382c3230352c31322c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3135302c3134362c3136332c3233362c31322c322c3233352c3135362c3137352c3234362c322c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c36382c3136382c3233352c3135362c3137352c3234362c322c36372c312c3132352c3136352c3139312c3136332c3233362c31322c322c3134342c3139342c3136382c3230352c31322c332c382c312c31302c312c31362c312c3233352c3135362c3137352c3234362c322c312c302c36385d2c2262613465643038612d366430362d343230652d393062312d663936653633643436346537223a5b322c322c3231352c3132382c3138392c3133302c322c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c36362c3136382c3231352c3132382c3138392c3133302c322c36352c312c3132352c3136352c3139312c3136332c3233362c31322c32382c3135322c3133382c3233302c36302c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3135322c3133382c3233302c36302c302c322c3130352c3130302c312c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c34302c302c3135322c3133382c3233302c36302c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3135322c3133382c3233302c36302c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3135322c3133382c3233302c36302c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3135322c3133382c3233302c36302c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3135322c3133382c3233302c36302c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3135322c3133382c3233302c36302c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3135322c3133382c3233302c36302c382c312c33392c302c3135322c3133382c3233302c36302c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3135322c3133382c3233302c36302c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134352c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c31312c342c3130302c39372c3131362c39372c312c3131392c312c39392c34302c302c3135322c3133382c3233302c36302c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3135322c3133382c3233302c36302c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134352c3134362c3136332c3233362c31322c3136312c3135322c3133382c3233302c36302c31302c312c33392c302c3135322c3133382c3233302c36302c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3135322c3133382c3233302c36302c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134382c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c31372c342c3130302c39372c3131362c39372c312c3131392c342c35352c35342c39392c3130382c34302c302c3135322c3133382c3233302c36302c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3135322c3133382c3233302c36302c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134382c3134362c3136332c3233362c31322c3136382c3135322c3133382c3233302c36302c31362c312c3132352c3134392c3134362c3136332c3233362c31322c33392c302c3135322c3133382c3233302c36302c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3135322c3133382c3233302c36302c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134392c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3135322c3133382c3233302c36302c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c352c34302c302c3135322c3133382c3233302c36302c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134392c3134362c3136332c3233362c31322c322c3135322c3133382c3233302c36302c332c382c312c31302c312c31362c312c3231352c3132382c3138392c3133302c322c312c302c36365d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2238653036326636312d643761652d346634622d383639632d663434633433313439333939222c2264383538396539382d383866632d343265342d383838632d623033333338626632326262225d2c2264617461626173655f72656c6174696f6e73223a7b2235613831623635622d666265382d343534662d623365632d373935633666316636336631223a2238653036326636312d643761652d346634622d383639632d663434633433313439333939227d7d\";\n\npub const DOC_3_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🇧🇸\"\n    },\n    \"name\": \"doc3\",\n    \"extra\": null,\n    \"layout\": 0,\n    \"view_id\": \"cc3c9716-914b-4e2c-860a-506c42bff8f8\",\n    \"created_at\": 1724146934,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1724866836\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"043832c3-c9c4-40e8-ae02-e25677ef344f\",\n      \"created_at\": 1724147455,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724147455\n    },\n    {\n      \"icon\": null,\n      \"name\": \"Shared\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"space_icon_1\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1724143277323}\",\n      \"layout\": 0,\n      \"view_id\": \"52adbe8e-57c1-43e9-8ef0-e7d49618aa27\",\n      \"created_at\": 1724319112,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724319112\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🇧🇸\"\n      },\n      \"name\": \"doc3\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"cc3c9716-914b-4e2c-860a-506c42bff8f8\",\n      \"created_at\": 1724146934,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724866836\n    }\n  ]\n}\n\"#;\npub const DOC_3_DOC_STATE_HEX: &str = \"090bf09cfed80b00000b2700c587a7ac0b01063362346f6877012800f09cfed80b0b0269640177063362346f68772800f09cfed80b0b027479017704677269642800f09cfed80b0b06706172656e7401772437653439303839652d656337322d353736632d386235302d6635373166343731326636612800f09cfed80b0b086368696c6472656e017706776949302d582800f09cfed80b0b04646174610177657b22706172656e745f6964223a2238653036326636312d643761652d346634622d383639632d663434633433313439333939222c22766965775f6964223a2264383538396539382d383866632d343265342d383838632d623033333338626632326262227d2800f09cfed80b0b0b65787465726e616c5f6964017e2800f09cfed80b0b0d65787465726e616c5f74797065017e2700c587a7ac0b0306776949302d580048c587a7ac0b180177063362346f687714c587a7ac0b002701046461746108646f63756d656e74012700c587a7ac0b0006626c6f636b73012700c587a7ac0b00046d657461012700c587a7ac0b020c6368696c6472656e5f6d6170012700c587a7ac0b0208746578745f6d6170012800c587a7ac0b0007706167655f696401772437653439303839652d656337322d353736632d386235302d6635373166343731326636612700c587a7ac0b012437653439303839652d656337322d353736632d386235302d663537316634373132663661012800c587a7ac0b0602696401772437653439303839652d656337322d353736632d386235302d6635373166343731326636612800c587a7ac0b06027479017704706167652800c587a7ac0b0606706172656e740177002800c587a7ac0b06086368696c6472656e01772437653439303839652d656337322d353736632d386235302d6635373166343731326636612800c587a7ac0b0604646174610177027b7d2800c587a7ac0b060b65787465726e616c5f6964017e2800c587a7ac0b060d65787465726e616c5f74797065017e2700c587a7ac0b032437653439303839652d656337322d353736632d386235302d663537316634373132663661002100c587a7ac0b010a68516e7a534d486767680100072100c587a7ac0b030a564b7a31425a61377049010100c587a7ac0b0e012100c587a7ac0b040a6e7a41643871564546620101e4c1b8aa0b002101046d6574610c6c6173745f73796e635f61745802afd8ead10600a1d3bffa590907a8afd8ead10606017a0000000066c7034215caa3c68e060000052100c587a7ac0b0106466f7a4158680100072100c587a7ac0b0306672d4134557101c1f09cfed80b14c587a7ac0b18012100c587a7ac0b0406754532536b79012100c587a7ac0b01065675347662450100072100c587a7ac0b03066c67516b51620181c587a7ac0b180100012100c587a7ac0b04064c4b4e5f7a52012100c587a7ac0b01066537394c79760100072100c587a7ac0b0306594b36664d530181caa3c68e0619012100c587a7ac0b0406536536695444012100c587a7ac0b01066f726130306c0100072100c587a7ac0b03063956574332480181caa3c68e0625010fa7b9e38804002100c587a7ac0b0406726e48724865012100c587a7ac0b010657764f6836450100072100c587a7ac0b03065263436c43460181caa3c68e06300100042100c587a7ac0b010674334c4d38500100072100c587a7ac0b03064e37326c7a5101c1caa3c68e0630a7b9e388040a012100c587a7ac0b0406387472776864012100c587a7ac0b01065234487434300100072100c587a7ac0b0306794b4255576e0181a7b9e388040a0101f5d2b382030000010bf1c6a19401002700c587a7ac0b0406423558507539022700c587a7ac0b010631506b566245012800f1c6a194010102696401770631506b5662452800f1c6a19401010274790177097061726167726170682800f1c6a194010106706172656e7401772437653439303839652d656337322d353736632d386235302d6635373166343731326636612800f1c6a1940101086368696c6472656e0177064c4d68736f412800f1c6a194010104646174610177027b7d2800f1c6a19401010b65787465726e616c5f69640177064235585075392800f1c6a19401010d65787465726e616c5f74797065017704746578742700c587a7ac0b03064c4d68736f410088a7b9e388042301770631506b56624502d3bffa59002101046d6574610c6c6173745f73796e635f617401a1e4c1b8aa0b570908f09cfed80b01000bd3bffa5901000ae4c1b8aa0b010058c587a7ac0b010f0bf5d2b38203010001a7b9e38804010024caa3c68e06010031afd8ead106010007\";\n\npub const VIEW_OF_GRID_1_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🏫\"\n    },\n    \"name\": \"View of grid1\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"d8589e98-88fc-42e4-888c-b03338bf22bb\",\n    \"created_at\": 1724146952,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1724867361\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"043832c3-c9c4-40e8-ae02-e25677ef344f\",\n      \"created_at\": 1724147455,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724147455\n    },\n    {\n      \"icon\": null,\n      \"name\": \"Shared\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"space_icon_1\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1724143277323}\",\n      \"layout\": 0,\n      \"view_id\": \"52adbe8e-57c1-43e9-8ef0-e7d49618aa27\",\n      \"created_at\": 1724319112,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724319112\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🍽️ \"\n      },\n      \"name\": \"grid1\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"8e062f61-d7ae-4f4b-869c-f44c43149399\",\n      \"created_at\": 1724146952,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724866836\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🏫\"\n      },\n      \"name\": \"View of grid1\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"d8589e98-88fc-42e4-888c-b03338bf22bb\",\n      \"created_at\": 1724146952,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1724867361\n    }\n  ]\n}\n\"#;\npub const VIEW_OF_GRID_1_DB_DATA: &str = \"7b2264617461626173655f636f6c6c6162223a5b34392c31372c3233312c3139322c3138392c3234312c31352c302c3136312c3235322c3231352c3137372c3136352c31332c312c312c33392c302c3235322c3231352c3137372c3136352c31332c332c33362c3130302c35362c35332c35362c35372c3130312c35372c35362c34352c35362c35362c3130322c39392c34352c35322c35302c3130312c35322c34352c35362c35362c35362c39392c34352c39382c34382c35312c35312c35312c35362c39382c3130322c35302c35302c39382c39382c312c34302c302c3233312c3139322c3138392c3234312c31352c312c322c3130352c3130302c312c3131392c33362c3130302c35362c35332c35362c35372c3130312c35372c35362c34352c35362c35362c3130322c39392c34352c35322c35302c3130312c35322c34352c35362c35362c35362c39392c34352c39382c34382c35312c35312c35312c35362c39382c3130322c35302c35302c39382c39382c34302c302c3233312c3139322c3138392c3234312c31352c312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3233312c3139322c3138392c3234312c31352c312c342c3131302c39372c3130392c3130312c312c3131392c31332c38362c3130352c3130312c3131392c33322c3131312c3130322c33322c3130332c3131342c3130352c3130302c34392c34302c302c3233312c3139322c3138392c3234312c31352c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c302c34302c302c3233312c3139322c3138392c3234312c31352c312c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3233312c3139322c3138392c3234312c31352c312c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3233312c3139322c3138392c3234312c31352c312c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3233312c3139322c3138392c3234312c31352c312c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3233312c3139322c3138392c3234312c31352c31332c332c3131382c312c322c3130352c3130302c3131392c362c3131332c37302c3131332c3131302c38362c3130312c3131382c312c322c3130352c3130302c3131392c362c34392c37312c37362c37302c37312c3131332c3131382c312c322c3130352c3130302c3131392c362c3131352c36382c37342c3131362c3132312c37362c33392c302c3233312c3139322c3138392c3234312c31352c312c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3233312c3139322c3138392c3234312c31352c31372c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c3131382c322c322c3130352c3130302c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c312c3133312c3233382c3136322c3139372c31352c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3233322c3233322c3234342c3136322c31352c302c3136312c3231352c3136312c3133362c3233322c31322c302c312c312c3231352c3233392c3138352c3138392c31342c302c3136312c3231352c3233302c3134382c3136332c342c302c312c312c3233362c3235352c3137392c3135392c31342c302c3136312c3231312c3139392c3133312c3235302c31302c302c312c312c3135352c3232332c3235302c3134382c31342c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3133312c3231382c3137312c3230372c31332c302c3136312c3135312c3232342c3134332c3137312c392c302c312c312c3136372c3133392c3234382c3139372c31332c302c3136312c3134362c3133382c3231332c3234342c362c302c312c36392c3235322c3231352c3137372c3136352c31332c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3235322c3231352c3137372c3136352c31332c302c322c3130352c3130302c312c33392c302c3235322c3231352c3137372c3136352c31332c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3235322c3231352c3137372c3136352c31332c342c332c3130352c3130352c3130302c312c3131392c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c33392c302c3235322c3231352c3137372c3136352c31332c322c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3235322c3231352c3137372c3136352c31332c362c322c3130352c3130302c312c3131392c362c3131332c37302c3131332c3131302c38362c3130312c34302c302c3235322c3231352c3137372c3136352c31332c362c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3235322c3231352c3137372c3136352c31332c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c362c322c3131362c3132312c312c3132352c302c33392c302c3235322c3231352c3137372c3136352c31332c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c31332c312c34382c312c34302c302c3235322c3231352c3137372c3136352c31332c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3235322c3231352c3137372c3136352c31332c322c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3235322c3231352c3137372c3136352c31332c31362c322c3130352c3130302c312c3131392c362c34392c37312c37362c37302c37312c3131332c34302c302c3235322c3231352c3137372c3136352c31332c31362c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3235322c3231352c3137372c3136352c31332c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3235322c3231352c3137372c3136352c31332c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3235322c3231352c3137372c3136352c31332c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235322c3231352c3137372c3136352c31332c31362c322c3131362c3132312c312c3132352c332c33392c302c3235322c3231352c3137372c3136352c31332c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c32332c312c35312c312c33332c302c3235322c3231352c3137372c3136352c31332c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3235322c3231352c3137372c3136352c31332c322c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3235322c3231352c3137372c3136352c31332c32362c322c3130352c3130302c312c3131392c362c3131352c36382c37342c3131362c3132312c37362c34302c302c3235322c3231352c3137372c3136352c31332c32362c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3235322c3231352c3137372c3136352c31332c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c32362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235322c3231352c3137372c3136352c31332c32362c322c3131362c3132312c312c3132352c352c33392c302c3235322c3231352c3137372c3136352c31332c32362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235322c3231352c3137372c3136352c31332c33332c312c35332c312c33392c302c3235322c3231352c3137372c3136352c31332c332c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c312c34302c302c3235322c3231352c3137372c3136352c31332c33352c322c3130352c3130302c312c3131392c33362c35362c3130312c34382c35342c35302c3130322c35342c34392c34352c3130302c35352c39372c3130312c34352c35322c3130322c35322c39382c34352c35362c35342c35372c39392c34352c3130322c35322c35322c39392c35322c35312c34392c35322c35372c35312c35372c35372c34302c302c3235322c3231352c3137372c3136352c31332c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3235322c3231352c3137372c3136352c31332c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3235322c3231352c3137372c3136352c31332c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c34302c302c3235322c3231352c3137372c3136352c31332c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33392c302c3235322c3231352c3137372c3136352c31332c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3235322c3231352c3137372c3136352c31332c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3235322c3231352c3137372c3136352c31332c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c34302c302c3235322c3231352c3137372c3136352c31332c34342c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3235322c3231352c3137372c3136352c31332c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c34382c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c33392c302c3235322c3231352c3137372c3136352c31332c34332c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3235322c3231352c3137372c3136352c31332c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132352c302c34302c302c3235322c3231352c3137372c3136352c31332c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3235322c3231352c3137372c3136352c31332c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132352c3135302c322c33392c302c3235322c3231352c3137372c3136352c31332c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3235322c3231352c3137372c3136352c31332c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235322c3231352c3137372c3136352c31332c35392c332c3131382c312c322c3130352c3130302c3131392c362c3131332c37302c3131332c3131302c38362c3130312c3131382c312c322c3130352c3130302c3131392c362c34392c37312c37362c37302c37312c3131332c3131382c312c322c3130352c3130302c3131392c362c3131352c36382c37342c3131362c3132312c37362c33392c302c3235322c3231352c3137372c3136352c31332c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235322c3231352c3137372c3136352c31332c36332c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c3131382c322c322c3130352c3130302c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3136312c3235322c3231352c3137372c3136352c31332c32302c312c3136312c3235322c3231352c3137372c3136352c31332c32352c312c3136312c3235322c3231352c3137372c3136352c31332c36372c312c3136312c3235322c3231352c3137372c3136352c31332c36382c312c3136382c3235322c3231352c3137372c3136352c31332c36392c312c3132352c3134382c3134362c3136332c3233362c31322c3136382c3235322c3231352c3137372c3136352c31332c37302c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c35352c35342c39392c3130382c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3132312c3130312c3131302c34352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c38322c3131322c34382c35332c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c312c3235332c3139382c3135372c3136312c31332c302c3136312c3133322c3234332c3138332c3234322c31302c302c312c312c3234372c3234312c3233392c3233332c31322c302c3136312c3231352c3136312c3133362c3233322c31322c302c312c312c3231352c3136312c3133362c3233322c31322c302c3136312c3135352c3232332c3235302c3134382c31342c302c312c312c3136342c3235352c3133352c3135392c31322c302c3136312c3234372c3234312c3233392c3233332c31322c302c312c312c3136302c3136332c3230342c3230302c31312c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3133352c3134322c3234302c3139332c31312c302c3136312c3137382c3135302c3234332c3136312c382c302c312c312c3233302c3233322c3136332c3139302c31312c302c3136312c3138302c3133302c3231352c3232312c352c302c312c312c3231302c3133382c3230362c3133342c31312c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3232302c3137372c3136382c3235332c31302c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c32392c312c3231312c3139392c3133312c3235302c31302c302c3136312c3230322c3136332c3230382c3137302c392c302c312c312c3133322c3234332c3138332c3234322c31302c302c3136312c3233302c3233322c3136332c3139302c31312c302c312c312c3138302c3137302c3137352c3135362c31302c302c3136312c3232382c3232392c3232392c3138342c382c302c312c312c3138312c3133362c3136392c3134332c31302c302c3136312c3135312c3232342c3134332c3137312c392c302c312c312c3135312c3232342c3134332c3137312c392c302c3136312c3232392c3133372c3133322c3234372c312c302c312c312c3233352c3136332c3230382c3137302c392c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3230322c3136332c3230382c3137302c392c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3232382c3232392c3232392c3138342c382c302c3136312c3137382c3135302c3234332c3136312c382c302c312c312c3233342c3139382c3234332c3138312c382c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3137362c3234302c3233302c3137392c382c302c3136312c3135352c3232332c3235302c3134382c31342c302c312c312c3137382c3135302c3234332c3136312c382c302c3136312c3232392c3133372c3133322c3234372c312c302c312c312c3132392c3135332c3139322c3233352c372c302c3136312c3235332c3139382c3135372c3136312c31332c302c312c312c3132382c3132392c3134392c3231352c372c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3234312c3134312c3232332c3137362c372c302c3136312c3230322c3136332c3230382c3137302c392c302c312c312c3134362c3133382c3231332c3234342c362c302c3136312c3133352c3134322c3234302c3139332c31312c302c312c312c3133382c3233312c3232392c3232392c362c302c3136312c3133322c3232352c3134382c3135392c332c302c312c312c3230342c3231322c3235332c3230372c362c302c3136312c3233312c3139322c3138392c3234312c31352c302c312c312c3233302c3136342c3134362c3234392c352c302c3136382c3132392c3135332c3139322c3233352c372c302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c312c3138302c3133302c3231352c3232312c352c302c3136312c3231352c3233392c3138352c3138392c31342c302c312c312c3139352c3232392c3137372c3231322c352c302c3136312c3234382c3137332c3230362c3230312c322c302c312c312c3231352c3233302c3134382c3136332c342c302c3136312c3133312c3231382c3137312c3230372c31332c302c312c322c3138352c3235322c3133372c3235352c332c302c3136312c3232302c3137372c3136382c3235332c31302c32382c332c3136382c3138352c3235322c3133372c3235352c332c322c312c3132322c302c302c302c302c3130322c3139392c332c36342c312c3133322c3232352c3134382c3135392c332c302c3136312c3235322c3231352c3137372c3136352c31332c312c312c312c3232312c3134392c3135382c3232392c322c302c3136312c3137362c3234302c3233302c3137392c382c302c312c312c3133392c3137342c3133352c3230332c322c302c3136312c3138312c3133362c3136392c3134332c31302c302c312c312c3234382c3137332c3230362c3230312c322c302c3136312c3133312c3233382c3136322c3139372c31352c302c312c312c3232392c3133372c3133322c3234372c312c302c3136312c3135382c3231382c3136342c3130302c302c312c312c3135342c3230302c3135312c3130392c302c3136312c3231312c3139392c3133312c3235302c31302c302c312c312c3135382c3231382c3136342c3130302c302c3136312c3233362c3235352c3137392c3135392c31342c302c312c312c3136362c3230342c3135322c35372c302c3136312c3233322c3233322c3234342c3136322c31352c302c312c312c3132392c3233382c3134322c34392c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c3137322c312c34382c3132382c3132392c3134392c3231352c372c312c302c312c3132392c3233382c3134322c34392c312c302c3137322c312c3132392c3135332c3139322c3233352c372c312c302c312c3133312c3233382c3136322c3139372c31352c312c302c312c3133322c3232352c3134382c3135392c332c312c302c312c3139352c3232392c3137372c3231322c352c312c302c312c3133312c3231382c3137312c3230372c31332c312c302c312c3133352c3134322c3234302c3139332c31312c312c302c312c3133322c3234332c3138332c3234322c31302c312c302c312c3133382c3233312c3232392c3232392c362c312c302c312c3230322c3136332c3230382c3137302c392c312c302c312c3230342c3231322c3235332c3230372c362c312c302c312c3133392c3137342c3133352c3230332c322c312c302c312c3231302c3133382c3230362c3133342c31312c312c302c312c3231312c3139392c3133312c3235302c31302c312c302c312c3134362c3133382c3231332c3234342c362c312c302c312c3231352c3136312c3133362c3233322c31322c312c302c312c3135312c3232342c3134332c3137312c392c312c302c312c3231352c3233302c3134382c3136332c342c312c302c312c3135342c3230302c3135312c3130392c312c302c312c3135352c3232332c3235302c3134382c31342c312c302c312c3232302c3137372c3136382c3235332c31302c312c302c32392c3232312c3134392c3135382c3232392c322c312c302c312c3135382c3231382c3136342c3130302c312c302c312c3231352c3233392c3138352c3138392c31342c312c302c312c3136302c3136332c3230342c3230302c31312c312c302c312c3136342c3235352c3133352c3135392c31322c312c302c312c3232392c3133372c3133322c3234372c312c312c302c312c3136362c3230342c3135322c35372c312c302c312c3233312c3139322c3138392c3234312c31352c312c302c312c3233322c3233322c3234342c3136322c31352c312c302c312c3232382c3232392c3232392c3138342c382c312c302c312c3233342c3139382c3234332c3138312c382c312c302c312c3233352c3136332c3230382c3137302c392c312c302c312c3233362c3235352c3137392c3135392c31342c312c302c312c3136372c3133392c3234382c3139372c31332c312c302c312c3233302c3233322c3136332c3139302c31312c312c302c312c3137362c3234302c3233302c3137392c382c312c302c312c3234312c3134312c3232332c3137362c372c312c302c312c3137382c3135302c3234332c3136312c382c312c302c312c3138302c3137302c3137352c3135362c31302c312c302c312c3138312c3133362c3136392c3134332c31302c312c302c312c3138302c3133302c3231352c3232312c352c312c302c312c3234372c3234312c3233392c3233332c31322c312c302c312c3234382c3137332c3230362c3230312c322c312c302c312c3138352c3235322c3133372c3235352c332c312c302c332c3235322c3231352c3137372c3136352c31332c342c312c312c32302c312c32352c312c36372c342c3235332c3139382c3135372c3136312c31332c312c302c315d2c2264617461626173655f726f775f636f6c6c616273223a7b2239383562326332342d653832362d346137352d613831322d626531393831323931616333223a5b352c322c3232362c3231392c3136302c3138332c31352c302c3136312c3136392c3137332c3138372c3132382c352c312c312c3136382c3232362c3231392c3136302c3138332c31352c302c312c3132322c302c302c302c302c3130322c3139392c332c36302c312c3234362c3139362c3138322c3230392c31332c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c31302c312c3134302c3138302c3232362c3232352c382c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c37312c32382c3232312c3134382c3233312c3139322c352c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3232312c3134382c3233312c3139322c352c302c322c3130352c3130302c312c3131392c33362c35372c35362c35332c39382c35302c39392c35302c35322c34352c3130312c35362c35302c35342c34352c35322c39372c35352c35332c34352c39372c35362c34392c35302c34352c39382c3130312c34392c35372c35362c34392c35302c35372c34392c39372c39392c35312c34302c302c3232312c3134382c3233312c3139322c352c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3232312c3134382c3233312c3139322c352c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3232312c3134382c3233312c3139322c352c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3232312c3134382c3233312c3139322c352c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3232312c3134382c3233312c3139322c352c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3232312c3134382c3233312c3139322c352c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3232312c3134382c3233312c3139322c352c382c312c33392c302c3232312c3134382c3233312c3139322c352c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3232312c3134382c3233312c3139322c352c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134342c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3232312c3134382c3233312c3139322c352c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130382c34302c302c3232312c3134382c3233312c3139322c352c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134342c3134362c3136332c3233362c31322c3136312c3232312c3134382c3233312c3139322c352c31302c312c33392c302c3232312c3134382c3233312c3139322c352c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3232312c3134382c3233312c3139322c352c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134372c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c31372c342c3130302c39372c3131362c39372c312c3131392c342c3132312c3130312c3131302c34352c34302c302c3232312c3134382c3233312c3139322c352c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3232312c3134382c3233312c3139322c352c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134372c3134362c3136332c3233362c31322c3136382c3232312c3134382c3233312c3139322c352c31362c312c3132352c3135302c3134362c3136332c3233362c31322c33392c302c3232312c3134382c3233312c3139322c352c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3232312c3134382c3233312c3139322c352c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3135302c3134362c3136332c3233362c31322c34302c302c3232312c3134382c3233312c3139322c352c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c352c34302c302c3232312c3134382c3233312c3139322c352c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3232312c3134382c3233312c3139322c352c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3135302c3134362c3136332c3233362c31322c312c3136392c3137332c3138372c3132382c352c302c3136312c3234362c3139362c3138322c3230392c31332c392c322c352c3136392c3137332c3138372c3132382c352c312c302c322c3232362c3231392c3136302c3138332c31352c312c302c312c3134302c3138302c3232362c3232352c382c312c302c37312c3232312c3134382c3233312c3139322c352c332c382c312c31302c312c31362c312c3234362c3139362c3138322c3230392c31332c312c302c31305d2c2265333133663030392d316136632d346637342d616363622d313262653236343161613633223a5b352c32382c3134342c3139342c3136382c3230352c31322c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3134342c3139342c3136382c3230352c31322c302c322c3130352c3130302c312c3131392c33362c3130312c35312c34392c35312c3130322c34382c34382c35372c34352c34392c39372c35342c39392c34352c35322c3130322c35352c35322c34352c39372c39392c39392c39382c34352c34392c35302c39382c3130312c35302c35342c35322c34392c39372c39372c35342c35312c34302c302c3134342c3139342c3136382c3230352c31322c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3134342c3139342c3136382c3230352c31322c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3134342c3139342c3136382c3230352c31322c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3134342c3139342c3136382c3230352c31322c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3134342c3139342c3136382c3230352c31322c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3134342c3139342c3136382c3230352c31322c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3134342c3139342c3136382c3230352c31322c382c312c33392c302c3134342c3139342c3136382c3230352c31322c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3134342c3139342c3136382c3230352c31322c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134342c3134362c3136332c3233362c31322c34302c302c3134342c3139342c3136382c3230352c31322c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130342c34302c302c3134342c3139342c3136382c3230352c31322c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3134342c3139342c3136382c3230352c31322c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134342c3134362c3136332c3233362c31322c3136312c3134342c3139342c3136382c3230352c31322c31302c312c33392c302c3134342c3139342c3136382c3230352c31322c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3134342c3139342c3136382c3230352c31322c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134362c3134362c3136332c3233362c31322c34302c302c3134342c3139342c3136382c3230352c31322c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3134342c3139342c3136382c3230352c31322c31372c342c3130302c39372c3131362c39372c312c3131392c342c38322c3131322c34382c35332c34302c302c3134342c3139342c3136382c3230352c31322c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134362c3134362c3136332c3233362c31322c3136312c3134342c3139342c3136382c3230352c31322c31362c312c33392c302c3134342c3139342c3136382c3230352c31322c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3134342c3139342c3136382c3230352c31322c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3135302c3134362c3136332c3233362c31322c33332c302c3134342c3139342c3136382c3230352c31322c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c33332c302c3134342c3139342c3136382c3230352c31322c32332c342c3130302c39372c3131362c39372c312c33332c302c3134342c3139342c3136382c3230352c31322c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c312c3133382c3137392c3133362c3138382c372c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c31322c372c3230332c3135362c3230322c3231372c352c302c3136312c3134342c3139342c3136382c3230352c31322c32322c312c3136382c3134342c3139342c3136382c3230352c31322c32352c312c3132322c302c302c302c302c302c302c302c352c3136312c3134342c3139342c3136382c3230352c31322c32362c312c3136312c3134342c3139342c3136382c3230352c31322c32372c312c3136382c3230332c3135362c3230322c3231372c352c302c312c3132322c302c302c302c302c3130322c3139382c3232372c3230312c3136382c3230332c3135362c3230322c3231372c352c322c312c3131392c332c38392c3130312c3131352c3136382c3230332c3135362c3230322c3231372c352c332c312c3132322c302c302c302c302c3130322c3139382c3232372c3230312c312c3233352c3135362c3137352c3234362c322c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c36392c322c3137312c3138312c3133302c3135352c322c302c3136312c3133382c3137392c3133362c3138382c372c31312c322c3136382c3137312c3138312c3133302c3135352c322c312c312c3132322c302c302c302c302c3130322c3139392c332c36302c352c3134342c3139342c3136382c3230352c31322c352c382c312c31302c312c31362c312c32322c312c32352c332c3133382c3137392c3133362c3138382c372c312c302c31322c3233352c3135362c3137352c3234362c322c312c302c36392c3230332c3135362c3230322c3231372c352c322c302c312c322c322c3137312c3138312c3133302c3135352c322c312c302c325d2c2262613465643038612d366430362d343230652d393062312d663936653633643436346537223a5b352c322c3234352c3133302c3138312c3232302c31352c302c3136312c3135362c3233302c3232362c3135312c372c312c312c3136382c3234352c3133302c3138312c3232302c31352c302c312c3132322c302c302c302c302c3130322c3139392c332c36302c312c3135362c3233302c3232362c3135312c372c302c3136312c3234302c3135312c3234342c3137332c352c392c322c312c3234302c3135312c3234342c3137332c352c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c31302c312c3231352c3132382c3138392c3133302c322c302c33332c312c342c3130392c3130312c3131362c39372c31322c3130382c39372c3131352c3131362c39352c3131352c3132312c3131302c39392c39352c39372c3131362c36372c32382c3135322c3133382c3233302c36302c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3135322c3133382c3233302c36302c302c322c3130352c3130302c312c3131392c33362c39382c39372c35322c3130312c3130302c34382c35362c39372c34352c35342c3130302c34382c35342c34352c35322c35302c34382c3130312c34352c35372c34382c39382c34392c34352c3130322c35372c35342c3130312c35342c35312c3130302c35322c35342c35322c3130312c35352c34302c302c3135322c3133382c3233302c36302c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39372c35362c34392c39382c35342c35332c39382c34352c3130322c39382c3130312c35362c34352c35322c35332c35322c3130322c34352c39382c35312c3130312c39392c34352c35352c35372c35332c39392c35342c3130322c34392c3130322c35342c35312c3130322c34392c34302c302c3135322c3133382c3233302c36302c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132352c36302c34302c302c3135322c3133382c3233302c36302c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3135322c3133382c3233302c36302c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3133312c3134362c3136332c3233362c31322c33332c302c3135322c3133382c3233302c36302c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3135322c3133382c3233302c36302c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3135322c3133382c3233302c36302c382c312c33392c302c3135322c3133382c3233302c36302c392c362c3131332c37302c3131332c3131302c38362c3130312c312c34302c302c3135322c3133382c3233302c36302c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134352c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c31312c342c3130302c39372c3131362c39372c312c3131392c312c39392c34302c302c3135322c3133382c3233302c36302c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c302c34302c302c3135322c3133382c3233302c36302c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134352c3134362c3136332c3233362c31322c3136312c3135322c3133382c3233302c36302c31302c312c33392c302c3135322c3133382c3233302c36302c392c362c34392c37312c37362c37302c37312c3131332c312c34302c302c3135322c3133382c3233302c36302c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134382c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c31372c342c3130302c39372c3131362c39372c312c3131392c342c35352c35342c39392c3130382c34302c302c3135322c3133382c3233302c36302c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c332c34302c302c3135322c3133382c3233302c36302c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134382c3134362c3136332c3233362c31322c3136382c3135322c3133382c3233302c36302c31362c312c3132352c3134392c3134362c3136332c3233362c31322c33392c302c3135322c3133382c3233302c36302c392c362c3131352c36382c37342c3131362c3132312c37362c312c34302c302c3135322c3133382c3233302c36302c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132352c3134392c3134362c3136332c3233362c31322c34302c302c3135322c3133382c3233302c36302c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3135322c3133382c3233302c36302c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132352c352c34302c302c3135322c3133382c3233302c36302c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132352c3134392c3134362c3136332c3233362c31322c352c3135322c3133382c3233302c36302c332c382c312c31302c312c31362c312c3234302c3135312c3234342c3137332c352c312c302c31302c3135362c3233302c3232362c3135312c372c312c302c322c3234352c3133302c3138312c3232302c31352c312c302c312c3231352c3132382c3138392c3133302c322c312c302c36375d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2264383538396539382d383866632d343265342d383838632d623033333338626632326262225d2c2264617461626173655f72656c6174696f6e73223a7b2233663964653338372d636131382d343232622d393262662d306263353231613461653339223a2233396332633365662d386431332d343339632d616166392d336534623434663363313434222c2235613831623635622d666265382d343534662d623365632d373935633666316636336631223a2238653036326636312d643761652d346634622d383639632d663434633433313439333939227d7d\";\n\npub const DOC_WITH_EMBEDDED_DB_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🦐\"\n    },\n    \"name\": \"docwithembeddeddb\",\n    \"extra\": null,\n    \"layout\": 0,\n    \"view_id\": \"070e5934-3c9d-4c96-96e7-f2ff0cb25762\",\n    \"created_at\": 1725381188,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1725381188\n  },\n  \"child_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"embeddeddb\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"bb221175-14da-4a05-a09d-595e42d2350f\",\n      \"created_at\": 1725381188,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381197\n    }\n  ],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1725381129,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381129\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🦐\"\n      },\n      \"name\": \"docwithembeddeddb\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"070e5934-3c9d-4c96-96e7-f2ff0cb25762\",\n      \"created_at\": 1725381188,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381188\n    }\n  ]\n}\n\n\"#;\npub const DOC_WITH_EMBEDDED_DB_HEX: &str = \"0301ece2869f09000100b1add37919060becfb839d070081ece2869f0905042700b1add3790106543777574255012800ecfb839d07040269640177065437775742552800ecfb839d0704027479017704677269642800ecfb839d070406706172656e7401772430303038353664302d383061642d353232302d383764632d3738303363303931303961652800ecfb839d0704086368696c6472656e0177063155666854692800ecfb839d070404646174610177657b22706172656e745f6964223a2230373065353933342d336339642d346339362d393665372d663266663063623235373632222c22766965775f6964223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066227d2800ecfb839d07040b65787465726e616c5f6964017e2800ecfb839d07040d65787465726e616c5f74797065017e2700b1add37903063155666854690048b1add379180177065437775742551ab1add379002701046461746108646f63756d656e74012700b1add3790006626c6f636b73012700b1add37900046d657461012700b1add379020c6368696c6472656e5f6d6170012700b1add3790208746578745f6d6170012800b1add3790007706167655f696401772430303038353664302d383061642d353232302d383764632d3738303363303931303961652700b1add379012430303038353664302d383061642d353232302d383764632d373830336330393130396165012800b1add3790602696401772430303038353664302d383061642d353232302d383764632d3738303363303931303961652800b1add37906027479017704706167652800b1add3790606706172656e740177002800b1add37906086368696c6472656e01772430303038353664302d383061642d353232302d383764632d3738303363303931303961652800b1add3790604646174610177027b7d2800b1add379060b65787465726e616c5f6964017e2800b1add379060d65787465726e616c5f74797065017e2700b1add379032430303038353664302d383061642d353232302d383764632d373830336330393130396165002700b1add379010a33785a73455a7a6d5037012800b1add3790f02696401770a33785a73455a7a6d50372800b1add3790f0274790177097061726167726170682800b1add3790f06706172656e7401772430303038353664302d383061642d353232302d383764632d3738303363303931303961652800b1add3790f086368696c6472656e01770a73393351416c59546a662800b1add3790f04646174610177027b7d2800b1add3790f0b65787465726e616c5f696401770a7173323547534c70756f2800b1add3790f0d65787465726e616c5f74797065017704746578742700b1add379030a73393351416c59546a66000800b1add3790e01770a33785a73455a7a6d50372700b1add379040a7173323547534c70756f0202ece2869f09010006ecfb839d07010004\";\n\npub const EMBEDDED_DB_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": null,\n    \"name\": \"embeddeddb\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"bb221175-14da-4a05-a09d-595e42d2350f\",\n    \"created_at\": 1725381188,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1725381215\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1725381129,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381129\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🦐\"\n      },\n      \"name\": \"docwithembeddeddb\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"070e5934-3c9d-4c96-96e7-f2ff0cb25762\",\n      \"created_at\": 1725381188,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381188\n    },\n    {\n      \"icon\": null,\n      \"name\": \"embeddeddb\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"bb221175-14da-4a05-a09d-595e42d2350f\",\n      \"created_at\": 1725381188,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725381215\n    }\n  ]\n}\n\"#;\npub const EMBEDDED_DB_HEX: &str=\"7b2264617461626173655f636f6c6c6162223a5b312c36392c3234332c3136312c3234382c3231342c352c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c34302c302c3234332c3136312c3234382c3231342c352c302c322c3130352c3130302c312c3131392c33362c35312c35372c34392c39392c35342c39372c35302c34392c34352c39392c34382c35322c39382c34352c35322c34382c35372c39382c34352c35362c35302c35322c34382c34352c3130312c35332c35302c35372c35312c39372c35332c34392c39382c35322c35342c35342c33392c302c3234332c3136312c3234382c3231342c352c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3234332c3136312c3234382c3231342c352c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3234332c3136312c3234382c3231342c352c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3234332c3136312c3234382c3231342c352c342c332c3130352c3130352c3130302c312c3131392c33362c39382c39382c35302c35302c34392c34392c35352c35332c34352c34392c35322c3130302c39372c34352c35322c39372c34382c35332c34352c39372c34382c35372c3130302c34352c35332c35372c35332c3130312c35322c35302c3130302c35302c35312c35332c34382c3130322c33392c302c3234332c3136312c3234382c3231342c352c322c362c36352c3131382c38362c36392c38372c3132322c312c34302c302c3234332c3136312c3234382c3231342c352c362c322c3130352c3130302c312c3131392c362c36352c3131382c38362c36392c38372c3132322c34302c302c3234332c3136312c3234382c3231342c352c362c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3234332c3136312c3234382c3231342c352c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c34302c302c3234332c3136312c3234382c3231342c352c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c36382c34302c302c3234332c3136312c3234382c3231342c352c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3234332c3136312c3234382c3231342c352c362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3234332c3136312c3234382c3231342c352c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3234332c3136312c3234382c3231342c352c31332c312c34382c312c34302c302c3234332c3136312c3234382c3231342c352c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3234332c3136312c3234382c3231342c352c322c362c34392c3131332c37392c38322c35372c37382c312c34302c302c3234332c3136312c3234382c3231342c352c31362c322c3130352c3130302c312c3131392c362c34392c3131332c37392c38322c35372c37382c34302c302c3234332c3136312c3234382c3231342c352c31362c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3234332c3136312c3234382c3231342c352c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c33332c302c3234332c3136312c3234382c3231342c352c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3234332c3136312c3234382c3231342c352c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3234332c3136312c3234382c3231342c352c31362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3234332c3136312c3234382c3231342c352c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3234332c3136312c3234382c3231342c352c32332c312c35312c312c33332c302c3234332c3136312c3234382c3231342c352c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3234332c3136312c3234382c3231342c352c322c362c38362c3131322c3130302c38392c34352c36352c312c34302c302c3234332c3136312c3234382c3231342c352c32362c322c3130352c3130302c312c3131392c362c38362c3131322c3130302c38392c34352c36352c34302c302c3234332c3136312c3234382c3231342c352c32362c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3234332c3136312c3234382c3231342c352c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c34302c302c3234332c3136312c3234382c3231342c352c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c36382c34302c302c3234332c3136312c3234382c3231342c352c32362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3234332c3136312c3234382c3231342c352c32362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3234332c3136312c3234382c3231342c352c32362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3234332c3136312c3234382c3231342c352c33332c312c35332c312c33392c302c3234332c3136312c3234382c3231342c352c332c33362c39382c39382c35302c35302c34392c34392c35352c35332c34352c34392c35322c3130302c39372c34352c35322c39372c34382c35332c34352c39372c34382c35372c3130302c34352c35332c35372c35332c3130312c35322c35302c3130302c35302c35312c35332c34382c3130322c312c34302c302c3234332c3136312c3234382c3231342c352c33352c322c3130352c3130302c312c3131392c33362c39382c39382c35302c35302c34392c34392c35352c35332c34352c34392c35322c3130302c39372c34352c35322c39372c34382c35332c34352c39372c34382c35372c3130302c34352c35332c35372c35332c3130312c35322c35302c3130302c35302c35312c35332c34382c3130322c34302c302c3234332c3136312c3234382c3231342c352c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35312c35372c34392c39392c35342c39372c35302c34392c34352c39392c34382c35322c39382c34352c35322c34382c35372c39382c34352c35362c35302c35322c34382c34352c3130312c35332c35302c35372c35312c39372c35332c34392c39382c35322c35342c35342c34302c302c3234332c3136312c3234382c3231342c352c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3234332c3136312c3234382c3231342c352c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c34302c302c3234332c3136312c3234382c3231342c352c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c33392c302c3234332c3136312c3234382c3231342c352c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3234332c3136312c3234382c3231342c352c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3234332c3136312c3234382c3231342c352c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3234332c3136312c3234382c3231342c352c34332c362c38362c3131322c3130302c38392c34352c36352c312c34302c302c3234332c3136312c3234382c3231342c352c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3234332c3136312c3234382c3231342c352c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3234332c3136312c3234382c3231342c352c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3234332c3136312c3234382c3231342c352c34332c362c36352c3131382c38362c36392c38372c3132322c312c34302c302c3234332c3136312c3234382c3231342c352c34382c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3234332c3136312c3234382c3231342c352c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3234332c3136312c3234382c3231342c352c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3234332c3136312c3234382c3231342c352c34332c362c34392c3131332c37392c38322c35372c37382c312c34302c302c3234332c3136312c3234382c3231342c352c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3234332c3136312c3234382c3231342c352c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3234332c3136312c3234382c3231342c352c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3234332c3136312c3234382c3231342c352c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3234332c3136312c3234382c3231342c352c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3234332c3136312c3234382c3231342c352c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3234332c3136312c3234382c3231342c352c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3234332c3136312c3234382c3231342c352c35392c332c3131382c312c322c3130352c3130302c3131392c362c36352c3131382c38362c36392c38372c3132322c3131382c312c322c3130352c3130302c3131392c362c34392c3131332c37392c38322c35372c37382c3131382c312c322c3130352c3130302c3131392c362c38362c3131322c3130302c38392c34352c36352c33392c302c3234332c3136312c3234382c3231342c352c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3234332c3136312c3234382c3231342c352c36332c332c3131382c322c322c3130352c3130302c3131392c33362c35312c35332c35362c35352c3130322c35332c39372c35322c34352c3130302c39372c35312c35342c34352c35322c35372c34392c35372c34352c39372c35342c35372c35302c34352c35322c35362c3130322c39392c35352c35322c35352c35372c35302c34382c35322c3130302c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c3130302c35352c3130302c35372c35342c34392c35342c35342c34352c3130322c39382c35342c35332c34352c35322c35352c34392c35312c34352c39372c39382c35312c35342c34352c35372c35372c39382c35322c35302c34392c3130302c35302c35312c35362c39372c39392c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c35302c3130312c35342c35362c35302c35322c35372c35302c34352c3130302c39372c3130302c39372c34352c35322c35332c35332c35342c34352c35362c35342c35322c35332c34352c35312c35302c34382c3130302c3130312c35302c35302c34382c35362c35302c35362c39372c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3136312c3234332c3136312c3234382c3231342c352c32302c312c3136312c3234332c3136312c3234382c3231342c352c32352c312c3136312c3234332c3136312c3234382c3231342c352c36372c312c3136312c3234332c3136312c3234382c3231342c352c36382c312c3136382c3234332c3136312c3234382c3231342c352c36392c312c3132322c302c302c302c302c3130322c3231352c35382c38352c3136382c3234332c3136312c3234382c3231342c352c37302c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c35322c37352c3131362c36372c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c37362c3131302c36372c37382c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3130352c35312c38372c38302c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c312c3234332c3136312c3234382c3231342c352c332c32302c312c32352c312c36372c345d2c2264617461626173655f726f775f636f6c6c616273223a7b2232653638323439322d646164612d343535362d383634352d333230646532323038323861223a5b312c32382c3133302c3234372c3232382c3231382c31332c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3133302c3234372c3232382c3231382c31332c302c322c3130352c3130302c312c3131392c33362c35302c3130312c35342c35362c35302c35322c35372c35302c34352c3130302c39372c3130302c39372c34352c35322c35332c35332c35342c34352c35362c35342c35322c35332c34352c35312c35302c34382c3130302c3130312c35302c35302c34382c35362c35302c35362c39372c34302c302c3133302c3234372c3232382c3231382c31332c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35312c35372c34392c39392c35342c39372c35302c34392c34352c39392c34382c35322c39382c34352c35322c34382c35372c39382c34352c35362c35302c35322c34382c34352c3130312c35332c35302c35372c35312c39372c35332c34392c39382c35322c35342c35342c34302c302c3133302c3234372c3232382c3231382c31332c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3133302c3234372c3232382c3231382c31332c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3133302c3234372c3232382c3231382c31332c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c33332c302c3133302c3234372c3232382c3231382c31332c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3133302c3234372c3232382c3231382c31332c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3133302c3234372c3232382c3231382c31332c382c312c33392c302c3133302c3234372c3232382c3231382c31332c392c362c36352c3131382c38362c36392c38372c3132322c312c34302c302c3133302c3234372c3232382c3231382c31332c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38312c34302c302c3133302c3234372c3232382c3231382c31332c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3133302c3234372c3232382c3231382c31332c31312c342c3130302c39372c3131362c39372c312c3131392c312c39392c34302c302c3133302c3234372c3232382c3231382c31332c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38312c3136312c3133302c3234372c3232382c3231382c31332c31302c312c33392c302c3133302c3234372c3232382c3231382c31332c392c362c34392c3131332c37392c38322c35372c37382c312c34302c302c3133302c3234372c3232382c3231382c31332c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38352c34302c302c3133302c3234372c3232382c3231382c31332c31372c342c3130302c39372c3131362c39372c312c3131392c342c35322c37352c3131362c36372c34302c302c3133302c3234372c3232382c3231382c31332c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3133302c3234372c3232382c3231382c31332c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38352c3136382c3133302c3234372c3232382c3231382c31332c31362c312c3132322c302c302c302c302c3130322c3231352c35382c38372c33392c302c3133302c3234372c3232382c3231382c31332c392c362c38362c3131322c3130302c38392c34352c36352c312c34302c302c3133302c3234372c3232382c3231382c31332c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38372c34302c302c3133302c3234372c3232382c3231382c31332c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3133302c3234372c3232382c3231382c31332c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3133302c3234372c3232382c3231382c31332c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38372c312c3133302c3234372c3232382c3231382c31332c332c382c312c31302c312c31362c315d2c2233353837663561342d646133362d343931392d613639322d343866633734373932303464223a5b312c32382c3139362c3233382c3233372c3136382c322c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139362c3233382c3233372c3136382c322c302c322c3130352c3130302c312c3131392c33362c35312c35332c35362c35352c3130322c35332c39372c35322c34352c3130302c39372c35312c35342c34352c35322c35372c34392c35372c34352c39372c35342c35372c35302c34352c35322c35362c3130322c39392c35352c35322c35352c35372c35302c34382c35322c3130302c34302c302c3139362c3233382c3233372c3136382c322c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35312c35372c34392c39392c35342c39372c35302c34392c34352c39392c34382c35322c39382c34352c35322c34382c35372c39382c34352c35362c35302c35322c34382c34352c3130312c35332c35302c35372c35312c39372c35332c34392c39382c35322c35342c35342c34302c302c3139362c3233382c3233372c3136382c322c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139362c3233382c3233372c3136382c322c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139362c3233382c3233372c3136382c322c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c33332c302c3139362c3233382c3233372c3136382c322c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139362c3233382c3233372c3136382c322c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3139362c3233382c3233372c3136382c322c382c312c33392c302c3139362c3233382c3233372c3136382c322c392c362c36352c3131382c38362c36392c38372c3132322c312c34302c302c3139362c3233382c3233372c3136382c322c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38302c34302c302c3139362c3233382c3233372c3136382c322c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139362c3233382c3233372c3136382c322c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130382c34302c302c3139362c3233382c3233372c3136382c322c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38302c3136312c3139362c3233382c3233372c3136382c322c31302c312c33392c302c3139362c3233382c3233372c3136382c322c392c362c34392c3131332c37392c38322c35372c37382c312c34302c302c3139362c3233382c3233372c3136382c322c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38312c34302c302c3139362c3233382c3233372c3136382c322c31372c342c3130302c39372c3131362c39372c312c3131392c342c3130352c35312c38372c38302c34302c302c3139362c3233382c3233372c3136382c322c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3139362c3233382c3233372c3136382c322c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38312c3136382c3139362c3233382c3233372c3136382c322c31362c312c3132322c302c302c302c302c3130322c3231352c35382c38372c33392c302c3139362c3233382c3233372c3136382c322c392c362c38362c3131322c3130302c38392c34352c36352c312c34302c302c3139362c3233382c3233372c3136382c322c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38372c34302c302c3139362c3233382c3233372c3136382c322c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3139362c3233382c3233372c3136382c322c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3139362c3233382c3233372c3136382c322c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38372c312c3139362c3233382c3233372c3136382c322c332c382c312c31302c312c31362c315d2c2264376439363136362d666236352d343731332d616233362d393962343231643233386163223a5b312c32382c3232372c3133392c3136332c3138332c382c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3232372c3133392c3136332c3138332c382c302c322c3130352c3130302c312c3131392c33362c3130302c35352c3130302c35372c35342c34392c35342c35342c34352c3130322c39382c35342c35332c34352c35322c35352c34392c35312c34352c39372c39382c35312c35342c34352c35372c35372c39382c35322c35302c34392c3130302c35302c35312c35362c39372c39392c34302c302c3232372c3133392c3136332c3138332c382c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35312c35372c34392c39392c35342c39372c35302c34392c34352c39392c34382c35322c39382c34352c35322c34382c35372c39382c34352c35362c35302c35322c34382c34352c3130312c35332c35302c35372c35312c39372c35332c34392c39382c35322c35342c35342c34302c302c3232372c3133392c3136332c3138332c382c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3232372c3133392c3136332c3138332c382c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3232372c3133392c3136332c3138332c382c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c36382c33332c302c3232372c3133392c3136332c3138332c382c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3232372c3133392c3136332c3138332c382c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3232372c3133392c3136332c3138332c382c382c312c33392c302c3232372c3133392c3136332c3138332c382c392c362c36352c3131382c38362c36392c38372c3132322c312c34302c302c3232372c3133392c3136332c3138332c382c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38302c34302c302c3232372c3133392c3136332c3138332c382c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3232372c3133392c3136332c3138332c382c31312c342c3130302c39372c3131362c39372c312c3131392c312c3130342c34302c302c3232372c3133392c3136332c3138332c382c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38302c3136312c3232372c3133392c3136332c3138332c382c31302c312c33392c302c3232372c3133392c3136332c3138332c382c392c362c34392c3131332c37392c38322c35372c37382c312c34302c302c3232372c3133392c3136332c3138332c382c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38332c34302c302c3232372c3133392c3136332c3138332c382c31372c342c3130302c39372c3131362c39372c312c3131392c342c37362c3131302c36372c37382c34302c302c3232372c3133392c3136332c3138332c382c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3232372c3133392c3136332c3138332c382c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38332c3136382c3232372c3133392c3136332c3138332c382c31362c312c3132322c302c302c302c302c3130322c3231352c35382c38362c33392c302c3232372c3133392c3136332c3138332c382c392c362c38362c3131322c3130302c38392c34352c36352c312c34302c302c3232372c3133392c3136332c3138332c382c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c35382c38362c34302c302c3232372c3133392c3136332c3138332c382c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3232372c3133392c3136332c3138332c382c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3232372c3133392c3136332c3138332c382c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c35382c38362c312c3232372c3133392c3136332c3138332c382c332c382c312c31302c312c31362c315d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2262623232313137352d313464612d346130352d613039642d353935653432643233353066225d2c2264617461626173655f72656c6174696f6e73223a7b2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335227d7d\";\n\npub const DB_WITH_REL_COL_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🌰\"\n    },\n    \"name\": \"grid3\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"17ce8c3e-8f35-4f1b-985a-7964edbc6ae0\",\n    \"created_at\": 1725421017,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1725558313\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1725474966,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725474966\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🌰\"\n      },\n      \"name\": \"grid3\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"17ce8c3e-8f35-4f1b-985a-7964edbc6ae0\",\n      \"created_at\": 1725421017,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725558313\n    }\n  ]\n}\n\"#;\npub const DB_WITH_REL_COL_HEX: &str = \"7b2264617461626173655f636f6c6c6162223a5b32302c312c3132392c3135392c3138352c3135302c31352c302c3136312c3137352c3235352c3139332c3231312c31302c302c312c312c3234312c3136312c3136342c3232302c31342c302c3136312c3232352c3231312c3138382c3230392c342c302c312c312c3139382c3133322c3133382c3133322c31322c302c3136312c3133332c3133342c3230392c3134332c372c302c312c312c3235342c3134352c3234382c3138352c31312c302c3136312c3230332c3136362c3133352c3134382c312c302c312c312c3232372c3134392c3137382c3134372c31312c302c3136312c3235342c3134352c3234382c3138352c31312c302c312c312c3137352c3235352c3139332c3231312c31302c302c3136312c3235332c3133352c3138392c3139342c332c302c312c312c3135382c3139312c3235302c3134322c31302c302c3136312c3232352c3235302c3134392c3132382c322c302c312c3134312c312c3136372c3233322c3133332c3234312c392c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3136372c3233322c3133332c3234312c392c302c322c3130352c3130302c312c33392c302c3136372c3233322c3133332c3234312c392c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3136372c3233322c3133332c3234312c392c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3136372c3233322c3133332c3234312c392c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3136372c3233322c3133332c3234312c392c342c332c3130352c3130352c3130302c312c3131392c33362c34392c35352c39392c3130312c35362c39392c35312c3130312c34352c35362c3130322c35312c35332c34352c35322c3130322c34392c39382c34352c35372c35362c35332c39372c34352c35352c35372c35342c35322c3130312c3130302c39382c39392c35342c39372c3130312c34382c33392c302c3136372c3233322c3133332c3234312c392c322c362c3130312c3131332c3130362c39382c3130322c37342c312c34302c302c3136372c3233322c3133332c3234312c392c362c322c3130352c3130302c312c3131392c362c3130312c3131332c3130362c39382c3130322c37342c34302c302c3136372c3233322c3133332c3234312c392c362c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3136372c3233322c3133332c3234312c392c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c34302c302c3136372c3233322c3133332c3234312c392c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c34302c302c3136372c3233322c3133332c3234312c392c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3136372c3233322c3133332c3234312c392c362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136372c3233322c3133332c3234312c392c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3136372c3233322c3133332c3234312c392c31332c312c34382c312c34302c302c3136372c3233322c3133332c3234312c392c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3136372c3233322c3133332c3234312c392c322c362c37372c36362c39372c38342c3131352c3131342c312c34302c302c3136372c3233322c3133332c3234312c392c31362c322c3130352c3130302c312c3131392c362c37372c36362c39372c38342c3131352c3131342c33332c302c3136372c3233322c3133332c3234312c392c31362c342c3131302c39372c3130392c3130312c312c34302c302c3136372c3233322c3133332c3234312c392c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c33332c302c3136372c3233322c3133332c3234312c392c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3136372c3233322c3133332c3234312c392c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c33332c302c3136372c3233322c3133332c3234312c392c31362c322c3131362c3132312c312c33392c302c3136372c3233322c3133332c3234312c392c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3136372c3233322c3133332c3234312c392c32332c312c35312c312c34302c302c3136372c3233322c3133332c3234312c392c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c3131392c33362c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c33332c302c3136372c3233322c3133332c3234312c392c322c362c3130382c3131362c3132322c37362c3130352c37332c312c302c382c33392c302c3136372c3233322c3133332c3234312c392c332c33362c34392c35352c39392c3130312c35362c39392c35312c3130312c34352c35362c3130322c35312c35332c34352c35322c3130322c34392c39382c34352c35372c35362c35332c39372c34352c35352c35372c35342c35322c3130312c3130302c39382c39392c35342c39372c3130312c34382c312c34302c302c3136372c3233322c3133332c3234312c392c33352c322c3130352c3130302c312c3131392c33362c34392c35352c39392c3130312c35362c39392c35312c3130312c34352c35362c3130322c35312c35332c34352c35322c3130322c34392c39382c34352c35372c35362c35332c39372c34352c35352c35372c35342c35322c3130312c3130302c39382c39392c35342c39372c3130312c34382c34302c302c3136372c3233322c3133332c3234312c392c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130302c34382c3130322c35312c35312c34392c3130322c34392c34352c35352c35322c3130322c39392c34352c35322c39372c35342c34382c34352c35362c34392c35372c35362c34352c39382c35342c35372c39372c3130322c35362c35372c39372c39382c39392c35372c39372c34302c302c3136372c3233322c3133332c3234312c392c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3136372c3233322c3133332c3234312c392c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c33332c302c3136372c3233322c3133332c3234312c392c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c33392c302c3136372c3233322c3133332c3234312c392c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3136372c3233322c3133332c3234312c392c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136372c3233322c3133332c3234312c392c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3136372c3233322c3133332c3234312c392c34332c362c37372c36362c39372c38342c3131352c3131342c312c34302c302c3136372c3233322c3133332c3234312c392c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3136372c3233322c3133332c3234312c392c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3136372c3233322c3133332c3234312c392c34342c342c3131392c3131342c39372c3131322c312c3132302c33332c302c3136372c3233322c3133332c3234312c392c34332c362c3130382c3131362c3132322c37362c3130352c37332c312c302c332c33392c302c3136372c3233322c3133332c3234312c392c34332c362c3130312c3131332c3130362c39382c3130322c37342c312c34302c302c3136372c3233322c3133332c3234312c392c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3136372c3233322c3133332c3234312c392c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3136372c3233322c3133332c3234312c392c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136372c3233322c3133332c3234312c392c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3136372c3233322c3133332c3234312c392c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3136372c3233322c3133332c3234312c392c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3136372c3233322c3133332c3234312c392c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3136372c3233322c3133332c3234312c392c35392c322c3131382c312c322c3130352c3130302c3131392c362c3130312c3131332c3130362c39382c3130322c37342c3131382c312c322c3130352c3130302c3131392c362c37372c36362c39372c38342c3131352c3131342c3132392c3136372c3233322c3133332c3234312c392c36312c312c33392c302c3136372c3233322c3133332c3234312c392c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3136372c3233322c3133332c3234312c392c36332c332c3131382c322c322c3130352c3130302c3131392c33362c3130322c35322c39382c35342c35362c3130302c34382c39392c34352c3130322c35362c39392c3130302c34352c35322c3130312c39392c35352c34352c35362c35322c35352c39392c34352c34382c39392c3130302c3130312c39392c3130302c39392c35352c39382c35352c35372c39372c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c35342c39382c39372c35332c35372c35332c35332c39392c34352c39392c35342c35312c34382c34352c35322c35372c3130312c3130302c34352c39382c35302c35352c35312c34352c3130302c35362c3130312c35342c39372c35312c35372c35332c35362c34392c39392c35352c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c35342c35372c35302c35342c34382c35342c3130312c35342c34352c34382c39372c35342c3130312c34352c35322c34382c35362c3130302c34352c35362c34392c3130312c35342c34352c35332c3130312c35372c35332c39392c3130302c39392c3130312c35332c35322c34392c3130322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3136312c3136372c3233322c3133332c3234312c392c34302c322c3136312c3136372c3233322c3133332c3234312c392c32302c312c3136312c3136372c3233322c3133332c3234312c392c31382c312c3136312c3136372c3233322c3133332c3234312c392c36392c312c3136312c3136372c3233322c3133332c3234312c392c37302c312c3136312c3136372c3233322c3133332c3234312c392c37312c312c3136312c3136372c3233322c3133332c3234312c392c37322c312c3136312c3136372c3233322c3133332c3234312c392c37332c312c3136312c3136372c3233322c3133332c3234312c392c37342c312c3136312c3136372c3233322c3133332c3234312c392c37352c312c3136312c3136372c3233322c3133332c3234312c392c37362c312c3136312c3136372c3233322c3133332c3234312c392c37372c312c3136312c3136372c3233322c3133332c3234312c392c37382c312c3136312c3136372c3233322c3133332c3234312c392c37392c312c3136312c3136372c3233322c3133332c3234312c392c38302c312c3136312c3136372c3233322c3133332c3234312c392c38312c312c3136312c3136372c3233322c3133332c3234312c392c38322c312c3136312c3136372c3233322c3133332c3234312c392c38332c312c3136312c3136372c3233322c3133332c3234312c392c38342c312c3136312c3136372c3233322c3133332c3234312c392c38352c312c3136312c3136372c3233322c3133332c3234312c392c38362c312c3136312c3136372c3233322c3133332c3234312c392c38372c312c3136312c3136372c3233322c3133332c3234312c392c38382c312c3136312c3136372c3233322c3133332c3234312c392c38392c312c3136312c3136372c3233322c3133332c3234312c392c39302c312c3136312c3136372c3233322c3133332c3234312c392c39312c312c3136312c3136372c3233322c3133332c3234312c392c39322c312c3136312c3136372c3233322c3133332c3234312c392c39332c312c3136312c3136372c3233322c3133332c3234312c392c39342c312c3136312c3136372c3233322c3133332c3234312c392c39352c312c3136312c3136372c3233322c3133332c3234312c392c39362c312c3136312c3136372c3233322c3133332c3234312c392c39372c312c3136312c3136372c3233322c3133332c3234312c392c39382c312c3136312c3136372c3233322c3133332c3234312c392c39392c312c3136312c3136372c3233322c3133332c3234312c392c3130302c312c3136312c3136372c3233322c3133332c3234312c392c3130312c312c3136312c3136372c3233322c3133332c3234312c392c3130322c312c3136312c3136372c3233322c3133332c3234312c392c3130332c312c3136312c3136372c3233322c3133332c3234312c392c3130342c312c3136312c3136372c3233322c3133332c3234312c392c3130352c312c3136312c3136372c3233322c3133332c3234312c392c3130362c312c3136312c3136372c3233322c3133332c3234312c392c3130372c312c3136312c3136372c3233322c3133332c3234312c392c3130382c312c3136312c3136372c3233322c3133332c3234312c392c3130392c312c3136312c3136372c3233322c3133332c3234312c392c3131302c312c3136312c3136372c3233322c3133332c3234312c392c3131312c312c3136312c3136372c3233322c3133332c3234312c392c3131322c312c3136312c3136372c3233322c3133332c3234312c392c3131332c312c3136312c3136372c3233322c3133332c3234312c392c3131342c312c3136312c3136372c3233322c3133332c3234312c392c3131352c312c3136312c3136372c3233322c3133332c3234312c392c3131362c312c3136312c3136372c3233322c3133332c3234312c392c3131372c312c3136312c3136372c3233322c3133332c3234312c392c3131382c312c3136312c3136372c3233322c3133332c3234312c392c3131392c312c3136312c3136372c3233322c3133332c3234312c392c3132302c312c3136312c3136372c3233322c3133332c3234312c392c3132312c312c3136312c3136372c3233322c3133332c3234312c392c3132322c312c3136312c3136372c3233322c3133332c3234312c392c3132332c312c3136312c3136372c3233322c3133332c3234312c392c3132342c312c3136312c3136372c3233322c3133332c3234312c392c3132352c312c3136312c3136372c3233322c3133332c3234312c392c3132362c312c3136312c3136372c3233322c3133332c3234312c392c3132372c312c3136312c3136372c3233322c3133332c3234312c392c3132382c312c312c3136312c3136372c3233322c3133332c3234312c392c3132392c312c312c3136312c3136372c3233322c3133332c3234312c392c3133302c312c312c3136312c3136372c3233322c3133332c3234312c392c3133312c312c312c3136312c3136372c3233322c3133332c3234312c392c3133322c312c312c3136312c3136372c3233322c3133332c3234312c392c3133332c312c312c3136312c3136372c3233322c3133332c3234312c392c3133342c312c312c3136312c3136372c3233322c3133332c3234312c392c3133352c312c312c3136312c3136372c3233322c3133332c3234312c392c3133362c312c312c3136312c3136372c3233322c3133332c3234312c392c3133372c312c312c3136312c3136372c3233322c3133332c3234312c392c3133382c312c312c3136312c3136372c3233322c3133332c3234312c392c3133392c312c312c3136312c3136372c3233322c3133332c3234312c392c3134302c312c312c3136312c3136372c3233322c3133332c3234312c392c3134312c312c312c3136382c3136372c3233322c3133332c3234312c392c3134322c312c312c3131392c31302c3131342c3130312c3130382c39372c3131362c3130312c39352c39392c3131312c3130382c3136312c3136372c3233322c3133332c3234312c392c3134332c312c312c3136382c3136372c3233322c3133332c3234312c392c32322c312c3132322c302c302c302c302c302c302c302c31302c33392c302c3136372c3233322c3133332c3234312c392c32332c322c34392c34382c312c33332c302c3136372c3233322c3133332c3234312c392c3134372c312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3136312c3136372c3233322c3133332c3234312c392c3134352c312c312c34302c302c3136372c3233322c3133332c3234312c392c32342c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c302c3136382c3136372c3233322c3133332c3234312c392c36382c312c3132322c302c302c302c302c3130322c3231352c3231342c31392c3136382c3136372c3233322c3133332c3234312c392c3134392c312c312c3132322c302c302c302c302c3130322c3231352c3231342c33302c3136382c3136372c3233322c3133332c3234312c392c3134382c312c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c312c3133332c3133342c3230392c3134332c372c302c3136312c3233372c3234372c3133342c3132382c372c302c312c312c3233372c3234372c3133342c3132382c372c302c3136312c3132392c3135392c3138352c3135302c31352c302c312c312c3136332c3230312c3135382c3235312c362c302c3136312c3234312c3136312c3136342c3232302c31342c302c312c312c3230382c3137332c3231392c3137382c362c302c3136312c3234372c3138382c3230342c3135332c332c302c312c312c3232352c3231312c3138382c3230392c342c302c3136312c3232312c3137332c3233382c3134372c322c302c312c312c3235332c3133352c3138392c3139342c332c302c3136312c3233382c3233302c3234352c3135302c312c302c312c312c3134372c3135332c3233392c3139332c332c302c3136382c3136332c3230312c3135382c3235312c362c302c312c3131392c33362c3130302c34382c3130322c35312c35312c34392c3130322c34392c34352c35352c35322c3130322c39392c34352c35322c39372c35342c34382c34352c35362c34392c35372c35362c34352c39382c35342c35372c39372c3130322c35362c35372c39372c39382c39392c35372c39372c312c3234372c3138382c3230342c3135332c332c302c3136312c3139382c3133322c3133382c3133322c31322c302c312c312c3232312c3137332c3233382c3134372c322c302c3136312c3135382c3139312c3235302c3134322c31302c302c312c312c3232352c3235302c3134392c3132382c322c302c3136312c3230382c3137332c3231392c3137382c362c302c312c312c3233382c3233302c3234352c3135302c312c302c3136312c3232372c3134392c3137382c3134372c31312c302c312c312c3230332c3136362c3133352c3134382c312c302c3136312c3136372c3233322c3133332c3234312c392c312c312c31392c3135382c3139312c3235302c3134322c31302c312c302c312c3132392c3135392c3138352c3135302c31352c312c302c312c3232352c3235302c3134392c3132382c322c312c302c312c3232372c3134392c3137382c3134372c31312c312c302c312c3232352c3231312c3138382c3230392c342c312c302c312c3133332c3133342c3230392c3134332c372c312c302c312c3139382c3133322c3133382c3133322c31322c312c302c312c3136372c3233322c3133332c3234312c392c31312c312c312c31382c312c32302c312c32322c312c32362c392c34302c312c34382c342c36322c312c36372c37372c3134352c312c312c3134382c312c322c3136332c3230312c3135382c3235312c362c312c302c312c3230332c3136362c3133352c3134382c312c312c302c312c3233372c3234372c3133342c3132382c372c312c302c312c3233382c3233302c3234352c3135302c312c312c302c312c3137352c3235352c3139332c3231312c31302c312c302c312c3230382c3137332c3231392c3137382c362c312c302c312c3234312c3136312c3136342c3232302c31342c312c302c312c3234372c3138382c3230342c3135332c332c312c302c312c3232312c3137332c3233382c3134372c322c312c302c312c3235332c3133352c3138392c3139342c332c312c302c312c3235342c3134352c3234382c3138352c31312c312c302c315d2c2264617461626173655f726f775f636f6c6c616273223a7b2236393236303665362d306136652d343038642d383165362d356539356364636535343166223a5b312c32362c3137352c3135322c3230302c3136362c31302c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3137352c3135322c3230302c3136362c31302c302c322c3130352c3130302c312c3131392c33362c35342c35372c35302c35342c34382c35342c3130312c35342c34352c34382c39372c35342c3130312c34352c35322c34382c35362c3130302c34352c35362c34392c3130312c35342c34352c35332c3130312c35372c35332c39392c3130302c39392c3130312c35332c35322c34392c3130322c34302c302c3137352c3135322c3230302c3136362c31302c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130302c34382c3130322c35312c35312c34392c3130322c34392c34352c35352c35322c3130322c39392c34352c35322c39372c35342c34382c34352c35362c34392c35372c35362c34352c39382c35342c35372c39372c3130322c35362c35372c39372c39382c39392c35372c39372c34302c302c3137352c3135322c3230302c3136362c31302c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3137352c3135322c3230302c3136362c31302c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3137352c3135322c3230302c3136362c31302c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c33332c302c3137352c3135322c3230302c3136362c31302c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3137352c3135322c3230302c3136362c31302c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3137352c3135322c3230302c3136362c31302c382c312c33392c302c3137352c3135322c3230302c3136362c31302c392c362c3130312c3131332c3130362c39382c3130322c37342c312c34302c302c3137352c3135322c3230302c3136362c31302c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3233342c33332c302c3137352c3135322c3230302c3136362c31302c31312c342c3130302c39372c3131362c39372c312c34302c302c3137352c3135322c3230302c3136362c31302c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c33332c302c3137352c3135322c3230302c3136362c31302c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3137352c3135322c3230302c3136362c31302c31302c312c3136382c3137352c3135322c3230302c3136362c31302c31332c312c3131392c312c35312c3136382c3137352c3135322c3230302c3136362c31302c31352c312c3132322c302c302c302c302c3130322c3231352c3231332c3233382c3136382c3137352c3135322c3230302c3136362c31302c31362c312c3132322c302c302c302c302c3130322c3231352c3231342c3138302c33392c302c3137352c3135322c3230302c3136362c31302c392c362c37372c36362c39372c38342c3131352c3131342c312c34302c302c3137352c3135322c3230302c3136362c31302c32302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231342c3138302c33392c302c3137352c3135322c3230302c3136362c31302c32302c342c3130302c39372c3131362c39372c302c382c302c3137352c3135322c3230302c3136362c31302c32322c312c3131392c33362c35302c35312c3130302c35332c3130312c34382c35332c35322c34352c35322c35302c39392c35362c34352c35322c35352c35332c35322c34352c39372c3130302c35342c35372c34352c35332c35302c35352c3130312c35322c3130322c3130322c39392c34392c3130312c35322c35342c34302c302c3137352c3135322c3230302c3136362c31302c32302c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c34302c302c3137352c3135322c3230302c3136362c31302c32302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231342c3138302c312c3137352c3135322c3230302c3136362c31302c342c382c312c31302c312c31332c312c31352c325d2c2236626135393535632d633633302d343965642d623237332d643865366133393538316337223a5b312c32372c3139302c3139342c3235302c3235352c31302c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139302c3139342c3235302c3235352c31302c302c322c3130352c3130302c312c3131392c33362c35342c39382c39372c35332c35372c35332c35332c39392c34352c39392c35342c35312c34382c34352c35322c35372c3130312c3130302c34352c39382c35302c35352c35312c34352c3130302c35362c3130312c35342c39372c35312c35372c35332c35362c34392c39392c35352c34302c302c3139302c3139342c3235302c3235352c31302c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130302c34382c3130322c35312c35312c34392c3130322c34392c34352c35352c35322c3130322c39392c34352c35322c39372c35342c34382c34352c35362c34392c35372c35362c34352c39382c35342c35372c39372c3130322c35362c35372c39372c39382c39392c35372c39372c34302c302c3139302c3139342c3235302c3235352c31302c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139302c3139342c3235302c3235352c31302c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139302c3139342c3235302c3235352c31302c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c33332c302c3139302c3139342c3235302c3235352c31302c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139302c3139342c3235302c3235352c31302c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3139302c3139342c3235302c3235352c31302c382c312c33392c302c3139302c3139342c3235302c3235352c31302c392c362c3130312c3131332c3130362c39382c3130322c37342c312c34302c302c3139302c3139342c3235302c3235352c31302c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3233362c34302c302c3139302c3139342c3235302c3235352c31302c31312c342c3130302c39372c3131362c39372c312c3131392c312c35302c34302c302c3139302c3139342c3235302c3235352c31302c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139302c3139342c3235302c3235352c31302c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3233362c3136312c3139302c3139342c3235302c3235352c31302c31302c312c33392c302c3139302c3139342c3235302c3235352c31302c392c362c37372c36362c39372c38342c3131352c3131342c312c34302c302c3139302c3139342c3235302c3235352c31302c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231342c3137352c34302c302c3139302c3139342c3235302c3235352c31302c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33332c302c3139302c3139342c3235302c3235352c31302c31372c342c3130302c39372c3131362c39372c312c302c312c33332c302c3139302c3139342c3235302c3235352c31302c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136382c3139302c3139342c3235302c3235352c31302c31362c312c3132322c302c302c302c302c3130322c3231352c3231342c3137372c3136372c3139302c3139342c3235302c3235352c31302c32302c302c382c302c3139302c3139342c3235302c3235352c31302c32342c322c3131392c33362c3130312c3130312c3130322c39382c35332c35352c34382c34382c34352c35362c39392c3130322c35352c34352c35322c34392c34392c3130312c34352c35372c35332c35372c35342c34352c3130322c35342c34382c39382c35372c39372c35332c34392c35372c34392c35342c3130312c3131392c33362c35302c35312c3130302c35332c3130312c34382c35332c35322c34352c35322c35302c39392c35362c34352c35322c35352c35332c35322c34352c39372c3130302c35342c35372c34352c35332c35302c35352c3130312c35322c3130322c3130322c39392c34392c3130312c35322c35342c3136382c3139302c3139342c3235302c3235352c31302c32322c312c3132322c302c302c302c302c3130322c3231352c3231342c3137372c312c3139302c3139342c3235302c3235352c31302c342c382c312c31302c312c31362c312c32302c335d2c2266346236386430632d663863642d346563372d383437632d306364656364633762373961223a5b312c32372c3137382c3138322c3139312c3134392c392c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3137382c3138322c3139312c3134392c392c302c322c3130352c3130302c312c3131392c33362c3130322c35322c39382c35342c35362c3130302c34382c39392c34352c3130322c35362c39392c3130302c34352c35322c3130312c39392c35352c34352c35362c35322c35352c39392c34352c34382c39392c3130302c3130312c39392c3130302c39392c35352c39382c35352c35372c39372c34302c302c3137382c3138322c3139312c3134392c392c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130302c34382c3130322c35312c35312c34392c3130322c34392c34352c35352c35322c3130322c39392c34352c35322c39372c35342c34382c34352c35362c34392c35372c35362c34352c39382c35342c35372c39372c3130322c35362c35372c39372c39382c39392c35372c39372c34302c302c3137382c3138322c3139312c3134392c392c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3137382c3138322c3139312c3134392c392c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3137382c3138322c3139312c3134392c392c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3231372c33332c302c3137382c3138322c3139312c3134392c392c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3137382c3138322c3139312c3134392c392c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3137382c3138322c3139312c3134392c392c382c312c33392c302c3137382c3138322c3139312c3134392c392c392c362c3130312c3131332c3130362c39382c3130322c37342c312c34302c302c3137382c3138322c3139312c3134392c392c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3233322c34302c302c3137382c3138322c3139312c3134392c392c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3137382c3138322c3139312c3134392c392c31312c342c3130302c39372c3131362c39372c312c3131392c312c34392c34302c302c3137382c3138322c3139312c3134392c392c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3233322c3136312c3137382c3138322c3139312c3134392c392c31302c312c33392c302c3137382c3138322c3139312c3134392c392c392c362c37372c36362c39372c38342c3131352c3131342c312c34302c302c3137382c3138322c3139312c3134392c392c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231342c3137312c34302c302c3137382c3138322c3139312c3134392c392c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33332c302c3137382c3138322c3139312c3134392c392c31372c342c3130302c39372c3131362c39372c312c302c312c33332c302c3137382c3138322c3139312c3134392c392c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136382c3137382c3138322c3139312c3134392c392c31362c312c3132322c302c302c302c302c3130322c3231352c3231342c3137322c3136372c3137382c3138322c3139312c3134392c392c32302c302c382c302c3137382c3138322c3139312c3134392c392c32342c322c3131392c33362c35352c35362c39382c3130312c3130312c3130322c35352c39382c34352c35352c39382c35372c39392c34352c35322c34392c35322c3130322c34352c39372c35302c3130302c34392c34352c35352c35342c3130322c3130312c35352c35342c35362c35352c39392c39382c39392c35312c3131392c33362c3130312c3130312c3130322c39382c35332c35352c34382c34382c34352c35362c39392c3130322c35352c34352c35322c34392c34392c3130312c34352c35372c35332c35372c35342c34352c3130322c35342c34382c39382c35372c39372c35332c34392c35372c34392c35342c3130312c3136382c3137382c3138322c3139312c3134392c392c32322c312c3132322c302c302c302c302c3130322c3231352c3231342c3137322c312c3137382c3138322c3139312c3134392c392c342c382c312c31302c312c31362c312c32302c335d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2231376365386333652d386633352d346631622d393835612d373936346564626336616530225d2c2264617461626173655f72656c6174696f6e73223a7b2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238227d7d\";\n\npub const RELATED_DB_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🧆\"\n    },\n    \"name\": \"grid2\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"5fc669fa-8867-4f6d-98f1-ce387597eabd\",\n    \"created_at\": 1725420991,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1725544606\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1725474966,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725474966\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🧆\"\n      },\n      \"name\": \"grid2\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"5fc669fa-8867-4f6d-98f1-ce387597eabd\",\n      \"created_at\": 1725420991,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725544606\n    }\n  ]\n}\n\"#;\npub const RELATED_DB_HEX: &str = \"7b2264617461626173655f636f6c6c6162223a5b31302c312c3231352c3230332c3234352c3230352c31352c302c3136312c3235332c3133312c3136302c3132382c31342c302c312c312c3235332c3133312c3136302c3132382c31342c302c3136312c3138342c3136362c3136332c3232312c352c302c312c312c3233342c3232382c3233342c3134362c31322c302c3136312c3231322c3232352c3234342c39332c302c312c39382c3136312c3232382c3133352c3234352c31302c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3136312c3232382c3133352c3234352c31302c302c322c3130352c3130302c312c33392c302c3136312c3232382c3133352c3234352c31302c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3136312c3232382c3133352c3234352c31302c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3136312c3232382c3133352c3234352c31302c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3136312c3232382c3133352c3234352c31302c342c332c3130352c3130352c3130302c312c3131392c33362c35332c3130322c39392c35342c35342c35372c3130322c39372c34352c35362c35362c35342c35352c34352c35322c3130322c35342c3130302c34352c35372c35362c3130322c34392c34352c39392c3130312c35312c35362c35352c35332c35372c35352c3130312c39372c39382c3130302c33392c302c3136312c3232382c3133352c3234352c31302c322c362c38392c3130392c3130312c3131322c36392c35322c312c34302c302c3136312c3232382c3133352c3234352c31302c362c322c3130352c3130302c312c3131392c362c38392c3130392c3130312c3131322c36392c35322c33332c302c3136312c3232382c3133352c3234352c31302c362c342c3131302c39372c3130392c3130312c312c34302c302c3136312c3232382c3133352c3234352c31302c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3136312c3232382c3133352c3234352c31302c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3136312c3232382c3133352c3234352c31302c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3136312c3232382c3133352c3234352c31302c362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136312c3232382c3133352c3234352c31302c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3136312c3232382c3133352c3234352c31302c31332c312c34382c312c34302c302c3136312c3232382c3133352c3234352c31302c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3136312c3232382c3133352c3234352c31302c322c362c35332c3131362c3130382c3130312c39302c38302c312c34302c302c3136312c3232382c3133352c3234352c31302c31362c322c3130352c3130302c312c3131392c362c35332c3131362c3130382c3130312c39302c38302c34302c302c3136312c3232382c3133352c3234352c31302c31362c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3136312c3232382c3133352c3234352c31302c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3136312c3232382c3133352c3234352c31302c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3136312c3232382c3133352c3234352c31302c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3136312c3232382c3133352c3234352c31302c31362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3136312c3232382c3133352c3234352c31302c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3136312c3232382c3133352c3234352c31302c32332c312c35312c312c33332c302c3136312c3232382c3133352c3234352c31302c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3136312c3232382c3133352c3234352c31302c322c362c36352c3130302c37392c3131302c3132322c39382c312c34302c302c3136312c3232382c3133352c3234352c31302c32362c322c3130352c3130302c312c3131392c362c36352c3130302c37392c3131302c3132322c39382c34302c302c3136312c3232382c3133352c3234352c31302c32362c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3136312c3232382c3133352c3234352c31302c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c34302c302c3136312c3232382c3133352c3234352c31302c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c34302c302c3136312c3232382c3133352c3234352c31302c32362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3136312c3232382c3133352c3234352c31302c32362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3136312c3232382c3133352c3234352c31302c32362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3136312c3232382c3133352c3234352c31302c33332c312c35332c312c33392c302c3136312c3232382c3133352c3234352c31302c332c33362c35332c3130322c39392c35342c35342c35372c3130322c39372c34352c35362c35362c35342c35352c34352c35322c3130322c35342c3130302c34352c35372c35362c3130322c34392c34352c39392c3130312c35312c35362c35352c35332c35372c35352c3130312c39372c39382c3130302c312c34302c302c3136312c3232382c3133352c3234352c31302c33352c322c3130352c3130302c312c3131392c33362c35332c3130322c39392c35342c35342c35372c3130322c39372c34352c35362c35362c35342c35352c34352c35322c3130322c35342c3130302c34352c35372c35362c3130322c34392c34352c39392c3130312c35312c35362c35352c35332c35372c35352c3130312c39372c39382c3130302c34302c302c3136312c3232382c3133352c3234352c31302c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c34302c302c3136312c3232382c3133352c3234352c31302c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3136312c3232382c3133352c3234352c31302c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3136312c3232382c3133352c3234352c31302c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c33392c302c3136312c3232382c3133352c3234352c31302c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3136312c3232382c3133352c3234352c31302c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136312c3232382c3133352c3234352c31302c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3136312c3232382c3133352c3234352c31302c34332c362c38392c3130392c3130312c3131322c36392c35322c312c34302c302c3136312c3232382c3133352c3234352c31302c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3136312c3232382c3133352c3234352c31302c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3136312c3232382c3133352c3234352c31302c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136312c3232382c3133352c3234352c31302c34332c362c35332c3131362c3130382c3130312c39302c38302c312c34302c302c3136312c3232382c3133352c3234352c31302c34382c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3136312c3232382c3133352c3234352c31302c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3136312c3232382c3133352c3234352c31302c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3136312c3232382c3133352c3234352c31302c34332c362c36352c3130302c37392c3131302c3132322c39382c312c34302c302c3136312c3232382c3133352c3234352c31302c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3136312c3232382c3133352c3234352c31302c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3136312c3232382c3133352c3234352c31302c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3136312c3232382c3133352c3234352c31302c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3136312c3232382c3133352c3234352c31302c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3136312c3232382c3133352c3234352c31302c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3136312c3232382c3133352c3234352c31302c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3136312c3232382c3133352c3234352c31302c35392c312c3131382c312c322c3130352c3130302c3131392c362c38392c3130392c3130312c3131322c36392c35322c3133362c3136312c3232382c3133352c3234352c31302c36302c322c3131382c312c322c3130352c3130302c3131392c362c35332c3131362c3130382c3130312c39302c38302c3131382c312c322c3130352c3130302c3131392c362c36352c3130302c37392c3131302c3132322c39382c33392c302c3136312c3232382c3133352c3234352c31302c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3136312c3232382c3133352c3234352c31302c36332c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35352c35362c39382c3130312c3130312c3130322c35352c39382c34352c35352c39382c35372c39392c34352c35322c34392c35322c3130322c34352c39372c35302c3130302c34392c34352c35352c35342c3130322c3130312c35352c35342c35362c35352c39392c39382c39392c35312c3131382c322c322c3130352c3130302c3131392c33362c3130312c3130312c3130322c39382c35332c35352c34382c34382c34352c35362c39392c3130322c35352c34352c35322c34392c34392c3130312c34352c35372c35332c35372c35342c34352c3130322c35342c34382c39382c35372c39372c35332c34392c35372c34392c35342c3130312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35302c35312c3130302c35332c3130312c34382c35332c35322c34352c35322c35302c39392c35362c34352c35322c35352c35332c35322c34352c39372c3130302c35342c35372c34352c35332c35302c35352c3130312c35322c3130322c3130322c39392c34392c3130312c35322c35342c3136312c3136312c3232382c3133352c3234352c31302c32302c312c3136312c3136312c3232382c3133352c3234352c31302c32352c312c3136312c3136312c3232382c3133352c3234352c31302c36372c312c3136312c3136312c3232382c3133352c3234352c31302c36382c312c3136382c3136312c3232382c3133352c3234352c31302c36392c312c3132322c302c302c302c302c3130322c3231352c3231332c3230312c3136382c3136312c3232382c3133352c3234352c31302c37302c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c39372c36352c39302c38362c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c37302c3131362c3131362c3130352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3130382c39302c39392c37352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c3136312c3136312c3232382c3133352c3234352c31302c34302c312c3132392c3136312c3232382c3133352c3234352c31302c36322c312c3136312c3136312c3232382c3133352c3234352c31302c31302c312c3136312c3136312c3232382c3133352c3234352c31302c382c312c3136382c3136312c3232382c3133352c3234352c31302c37332c312c3132322c302c302c302c302c3130322c3231352c3231342c3134352c3230332c3136312c3232382c3133352c3234352c31302c36302c3136312c3232382c3133352c3234352c31302c36312c3133312c322c3136312c3232382c3133352c3234352c31302c36302c3136312c3136312c3232382c3133352c3234352c31302c37352c312c3136312c3136312c3232382c3133352c3234352c31302c37362c312c3136312c3136312c3232382c3133352c3234352c31302c37392c312c3136312c3136312c3232382c3133352c3234352c31302c38302c312c3136312c3136312c3232382c3133352c3234352c31302c38312c312c3136312c3136312c3232382c3133352c3234352c31302c38322c312c3136312c3136312c3232382c3133352c3234352c31302c38332c312c3136312c3136312c3232382c3133352c3234352c31302c38342c312c3136312c3136312c3232382c3133352c3234352c31302c38352c312c3136312c3136312c3232382c3133352c3234352c31302c38362c312c3136312c3136312c3232382c3133352c3234352c31302c38372c312c3136312c3136312c3232382c3133352c3234352c31302c38382c312c3136312c3136312c3232382c3133352c3234352c31302c38392c312c3136312c3136312c3232382c3133352c3234352c31302c39302c312c3136312c3136312c3232382c3133352c3234352c31302c39312c312c3136312c3136312c3232382c3133352c3234352c31302c39322c312c3136312c3136312c3232382c3133352c3234352c31302c39332c312c3136312c3136312c3232382c3133352c3234352c31302c39342c312c3136312c3136312c3232382c3133352c3234352c31302c39352c312c3136312c3136312c3232382c3133352c3234352c31302c39362c312c3136382c3136312c3232382c3133352c3234352c31302c39372c312c3132322c302c302c302c302c3130322c3231352c3231342c3135342c3136382c3136312c3232382c3133352c3234352c31302c39382c312c3131392c332c3130352c3130302c36332c312c3233332c3137392c3233362c3232312c31302c302c3136382c3139332c3136302c3136372c3133322c322c302c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c312c3135312c3133352c3138392c3135382c392c302c3136312c3231352c3230332c3234352c3230352c31352c302c312c312c3235352c3231392c3138352c3134332c382c302c3136312c3136312c3232382c3133352c3234352c31302c312c312c312c3138342c3136362c3136332c3232312c352c302c3136312c3233342c3232382c3233342c3134362c31322c302c312c312c3139332c3136302c3136372c3133322c322c302c3136312c3135312c3133352c3138392c3135382c392c302c312c312c3231322c3232352c3234342c39332c302c3136312c3235352c3231392c3138352c3134332c382c302c312c392c3136312c3232382c3133352c3234352c31302c392c312c312c382c312c31302c312c32302c312c32352c312c34302c312c36372c342c37332c342c37392c32302c3139332c3136302c3136372c3133322c322c312c302c312c3231322c3232352c3234342c39332c312c302c312c3135312c3133352c3138392c3135382c392c312c302c312c3138342c3136362c3136332c3232312c352c312c302c312c3231352c3230332c3234352c3230352c31352c312c302c312c3233342c3232382c3233342c3134362c31322c312c302c312c3235332c3133312c3136302c3132382c31342c312c302c312c3235352c3231392c3138352c3134332c382c312c302c315d2c2264617461626173655f726f775f636f6c6c616273223a7b2265656662353730302d386366372d343131652d393539362d663630623961353139313665223a5b332c332c3231392c3231302c3230312c3230362c31332c302c3136312c3235302c3133332c3137372c3137392c382c32382c312c3136312c3235302c3133332c3137372c3137392c382c32392c312c3136312c3235302c3133332c3137372c3137392c382c33302c312c33312c3235302c3133332c3137372c3137392c382c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3235302c3133332c3137372c3137392c382c302c322c3130352c3130302c312c3131392c33362c3130312c3130312c3130322c39382c35332c35352c34382c34382c34352c35362c39392c3130322c35352c34352c35322c34392c34392c3130312c34352c35372c35332c35372c35342c34352c3130322c35342c34382c39382c35372c39372c35332c34392c35372c34392c35342c3130312c34302c302c3235302c3133332c3137372c3137392c382c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c34302c302c3235302c3133332c3137372c3137392c382c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3235302c3133332c3137372c3137392c382c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3235302c3133332c3137372c3137392c382c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3235302c3133332c3137372c3137392c382c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3235302c3133332c3137372c3137392c382c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3235302c3133332c3137372c3137392c382c382c312c33392c302c3235302c3133332c3137372c3137392c382c392c362c38392c3130392c3130312c3131322c36392c35322c312c34302c302c3235302c3133332c3137372c3137392c382c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139342c33332c302c3235302c3133332c3137372c3137392c382c31312c342c3130302c39372c3131362c39372c312c34302c302c3235302c3133332c3137372c3137392c382c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c33332c302c3235302c3133332c3137372c3137392c382c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3235302c3133332c3137372c3137392c382c31302c312c33392c302c3235302c3133332c3137372c3137392c382c392c362c35332c3131362c3130382c3130312c39302c38302c312c34302c302c3235302c3133332c3137372c3137392c382c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139392c34302c302c3235302c3133332c3137372c3137392c382c31372c342c3130302c39372c3131362c39372c312c3131392c342c37302c3131362c3131362c3130352c34302c302c3235302c3133332c3137372c3137392c382c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3235302c3133332c3137372c3137392c382c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3139392c3136312c3235302c3133332c3137372c3137392c382c31362c312c33392c302c3235302c3133332c3137372c3137392c382c392c362c36352c3130302c37392c3131302c3132322c39382c312c34302c302c3235302c3133332c3137372c3137392c382c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3230322c34302c302c3235302c3133332c3137372c3137392c382c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3235302c3133332c3137372c3137392c382c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3235302c3133332c3137372c3137392c382c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3230322c3136312c3235302c3133332c3137372c3137392c382c32322c312c3136312c3235302c3133332c3137372c3137392c382c31332c312c3136312c3235302c3133332c3137372c3137392c382c31352c312c332c3134382c3136372c3134362c3136362c352c302c3136382c3231392c3231302c3230312c3230362c31332c302c312c3132322c302c302c302c302c3130322c3231372c3138342c3135382c3136382c3231392c3231302c3230312c3230362c31332c312c312c3131392c332c39382c39372c3131342c3136382c3231392c3231302c3230312c3230362c31332c322c312c3132322c302c302c302c302c3130322c3231372c3138342c3135382c322c3235302c3133332c3137372c3137392c382c362c382c312c31302c312c31332c312c31352c322c32322c312c32382c332c3231392c3231302c3230312c3230362c31332c312c302c335d2c2232336435653035342d343263382d343735342d616436392d353237653466666331653436223a5b312c33342c3139312c3231302c3136312c3137382c332c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139312c3231302c3136312c3137382c332c302c322c3130352c3130302c312c3131392c33362c35302c35312c3130302c35332c3130312c34382c35332c35322c34352c35322c35302c39392c35362c34352c35322c35352c35332c35322c34352c39372c3130302c35342c35372c34352c35332c35302c35352c3130312c35322c3130322c3130322c39392c34392c3130312c35322c35342c34302c302c3139312c3231302c3136312c3137382c332c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c34302c302c3139312c3231302c3136312c3137382c332c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139312c3231302c3136312c3137382c332c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139312c3231302c3136312c3137382c332c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3139312c3231302c3136312c3137382c332c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139312c3231302c3136312c3137382c332c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3139312c3231302c3136312c3137382c332c382c312c33392c302c3139312c3231302c3136312c3137382c332c392c362c38392c3130392c3130312c3131322c36392c35322c312c34302c302c3139312c3231302c3136312c3137382c332c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139352c33332c302c3139312c3231302c3136312c3137382c332c31312c342c3130302c39372c3131362c39372c312c34302c302c3139312c3231302c3136312c3137382c332c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c33332c302c3139312c3231302c3136312c3137382c332c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3139312c3231302c3136312c3137382c332c31302c312c33392c302c3139312c3231302c3136312c3137382c332c392c362c35332c3131362c3130382c3130312c39302c38302c312c34302c302c3139312c3231302c3136312c3137382c332c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3230312c33332c302c3139312c3231302c3136312c3137382c332c31372c342c3130302c39372c3131362c39372c312c34302c302c3139312c3231302c3136312c3137382c332c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c33332c302c3139312c3231302c3136312c3137382c332c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3139312c3231302c3136312c3137382c332c31362c312c3136382c3139312c3231302c3136312c3137382c332c31392c312c3131392c342c3130382c39302c39392c37352c3136382c3139312c3231302c3136312c3137382c332c32312c312c3132322c302c302c302c302c3130322c3231352c3231332c3230312c3136312c3139312c3231302c3136312c3137382c332c32322c312c33392c302c3139312c3231302c3136312c3137382c332c392c362c36352c3130302c37392c3131302c3132322c39382c312c34302c302c3139312c3231302c3136312c3137382c332c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3230322c34302c302c3139312c3231302c3136312c3137382c332c32362c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3139312c3231302c3136312c3137382c332c32362c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3139312c3231302c3136312c3137382c332c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3230322c3136382c3139312c3231302c3136312c3137382c332c32352c312c3132322c302c302c302c302c3130322c3231352c3231342c36362c3136382c3139312c3231302c3136312c3137382c332c31332c312c3131392c332c39382c39372c3132322c3136382c3139312c3231302c3136312c3137382c332c31352c312c3132322c302c302c302c302c3130322c3231352c3231342c36362c312c3139312c3231302c3136312c3137382c332c372c382c312c31302c312c31332c312c31352c322c31392c312c32312c322c32352c315d2c2237386265656637622d376239632d343134662d613264312d373666653736383763626333223a5b332c33312c3139392c3138322c3136352c3135332c31342c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139392c3138322c3136352c3135332c31342c302c322c3130352c3130302c312c3131392c33362c35352c35362c39382c3130312c3130312c3130322c35352c39382c34352c35352c39382c35372c39392c34352c35322c34392c35322c3130322c34352c39372c35302c3130302c34392c34352c35352c35342c3130322c3130312c35352c35342c35362c35352c39392c39382c39392c35312c34302c302c3139392c3138322c3136352c3135332c31342c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c35352c39372c34392c35362c35362c34382c35302c34352c34392c35332c35372c35322c34352c35322c3130302c35322c35322c34352c35372c35332c35322c35302c34352c35312c35362c39392c35352c39382c35322c35352c34392c3130312c39382c39392c35302c34302c302c3139392c3138322c3136352c3135332c31342c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139392c3138322c3136352c3135332c31342c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139392c3138322c3136352c3135332c31342c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139312c33332c302c3139392c3138322c3136352c3135332c31342c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139392c3138322c3136352c3135332c31342c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3139392c3138322c3136352c3135332c31342c382c312c33392c302c3139392c3138322c3136352c3135332c31342c392c362c38392c3130392c3130312c3131322c36392c35322c312c34302c302c3139392c3138322c3136352c3135332c31342c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139342c33332c302c3139392c3138322c3136352c3135332c31342c31312c342c3130302c39372c3131362c39372c312c34302c302c3139392c3138322c3136352c3135332c31342c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c33332c302c3139392c3138322c3136352c3135332c31342c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3139392c3138322c3136352c3135332c31342c31302c312c33392c302c3139392c3138322c3136352c3135332c31342c392c362c35332c3131362c3130382c3130312c39302c38302c312c34302c302c3139392c3138322c3136352c3135332c31342c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3139352c34302c302c3139392c3138322c3136352c3135332c31342c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3139392c3138322c3136352c3135332c31342c31372c342c3130302c39372c3131362c39372c312c3131392c342c3130382c39302c39392c37352c34302c302c3139392c3138322c3136352c3135332c31342c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3139352c3136312c3139392c3138322c3136352c3135332c31342c31362c312c33392c302c3139392c3138322c3136352c3135332c31342c392c362c36352c3130302c37392c3131302c3132322c39382c312c34302c302c3139392c3138322c3136352c3135332c31342c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3231352c3231332c3230332c34302c302c3139392c3138322c3136352c3135332c31342c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3139392c3138322c3136352c3135332c31342c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3139392c3138322c3136352c3135332c31342c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3231352c3231332c3230332c3136312c3139392c3138322c3136352c3135332c31342c32322c312c3136312c3139392c3138322c3136352c3135332c31342c31332c312c3136312c3139392c3138322c3136352c3135332c31342c31352c312c332c3137392c3139382c3234372c3232382c31332c302c3136382c3233342c3234352c3139322c3230302c31312c302c312c3132322c302c302c302c302c3130322c3231372c3138342c3135352c3136382c3233342c3234352c3139322c3230302c31312c312c312c3131392c332c3130322c3131312c3131312c3136382c3233342c3234352c3139322c3230302c31312c322c312c3132322c302c302c302c302c3130322c3231372c3138342c3135352c332c3233342c3234352c3139322c3230302c31312c302c3136312c3139392c3138322c3136352c3135332c31342c32382c312c3136312c3139392c3138322c3136352c3135332c31342c32392c312c3136312c3139392c3138322c3136352c3135332c31342c33302c312c322c3233342c3234352c3139322c3230302c31312c312c302c332c3139392c3138322c3136352c3135332c31342c362c382c312c31302c312c31332c312c31352c322c32322c312c32382c335d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2235666336363966612d383836372d346636642d393866312d636533383735393765616264225d2c2264617461626173655f72656c6174696f6e73223a7b2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533227d7d\";\n\npub const DB_ROW_WITH_DOC_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🥖\"\n    },\n    \"name\": \"db_with_row_doc\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"8a3fc629-ad13-42ef-a2d2-f21ceb4c1be7\",\n    \"created_at\": 1725722204,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1725983499\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1725722204,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725722204\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🥖\"\n      },\n      \"name\": \"db_with_row_doc\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"8a3fc629-ad13-42ef-a2d2-f21ceb4c1be7\",\n      \"created_at\": 1725722204,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725983499\n    }\n  ]\n}\n\"#;\npub const DB_ROW_WITH_DOC_HEX :&str = \"7b2264617461626173655f636f6c6c6162223a5b31342c312c3136312c3231362c3139342c3233382c31352c302c3136312c3231332c3134302c3135352c3139312c31352c302c312c312c3139362c3139372c3133362c3139372c31352c302c3136312c3234392c3233382c3230392c3235322c322c302c312c312c3231332c3134302c3135352c3139312c31352c302c3136312c3230382c3234312c3138332c3233312c312c302c312c36392c3134382c3135312c3230352c3231332c31332c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3134382c3135312c3230352c3231332c31332c302c322c3130352c3130302c312c33392c302c3134382c3135312c3230352c3231332c31332c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3134382c3135312c3230352c3231332c31332c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3134382c3135312c3230352c3231332c31332c302c352c3130392c3130312c3131362c39372c3131352c312c34302c302c3134382c3135312c3230352c3231332c31332c342c332c3130352c3130352c3130302c312c3131392c33362c35362c39372c35312c3130322c39392c35342c35302c35372c34352c39372c3130302c34392c35312c34352c35322c35302c3130312c3130322c34352c39372c35302c3130302c35302c34352c3130322c35302c34392c39392c3130312c39382c35322c39392c34392c39382c3130312c35352c33392c302c3134382c3135312c3230352c3231332c31332c322c362c38322c37382c3131312c37312c37382c3130372c312c34302c302c3134382c3135312c3230352c3231332c31332c362c322c3130352c3130302c312c3131392c362c38322c37382c3131312c37312c37382c3130372c34302c302c3134382c3135312c3230352c3231332c31332c362c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3134382c3135312c3230352c3231332c31332c362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c34302c302c3134382c3135312c3230352c3231332c31332c362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c34302c302c3134382c3135312c3230352c3231332c31332c362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3134382c3135312c3230352c3231332c31332c362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3134382c3135312c3230352c3231332c31332c362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3134382c3135312c3230352c3231332c31332c31332c312c34382c312c34302c302c3134382c3135312c3230352c3231332c31332c31342c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3134382c3135312c3230352c3231332c31332c322c362c35342c38382c39302c3132312c35362c37382c312c34302c302c3134382c3135312c3230352c3231332c31332c31362c322c3130352c3130302c312c3131392c362c35342c38382c39302c3132312c35362c37382c34302c302c3134382c3135312c3230352c3231332c31332c31362c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3134382c3135312c3230352c3231332c31332c31362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c33332c302c3134382c3135312c3230352c3231332c31332c31362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3134382c3135312c3230352c3231332c31332c31362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3134382c3135312c3230352c3231332c31332c31362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3134382c3135312c3230352c3231332c31332c31362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3134382c3135312c3230352c3231332c31332c32332c312c35312c312c33332c302c3134382c3135312c3230352c3231332c31332c32342c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3134382c3135312c3230352c3231332c31332c322c362c3130312c3131312c35342c37312c37302c3131342c312c34302c302c3134382c3135312c3230352c3231332c31332c32362c322c3130352c3130302c312c3131392c362c3130312c3131312c35342c37312c37302c3131342c34302c302c3134382c3135312c3230352c3231332c31332c32362c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3134382c3135312c3230352c3231332c31332c32362c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c34302c302c3134382c3135312c3230352c3231332c31332c32362c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c34302c302c3134382c3135312c3230352c3231332c31332c32362c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3134382c3135312c3230352c3231332c31332c32362c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3134382c3135312c3230352c3231332c31332c32362c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3134382c3135312c3230352c3231332c31332c33332c312c35332c312c33392c302c3134382c3135312c3230352c3231332c31332c332c33362c35362c39372c35312c3130322c39392c35342c35302c35372c34352c39372c3130302c34392c35312c34352c35322c35302c3130312c3130322c34352c39372c35302c3130302c35302c34352c3130322c35302c34392c39392c3130312c39382c35322c39392c34392c39382c3130312c35352c312c34302c302c3134382c3135312c3230352c3231332c31332c33352c322c3130352c3130302c312c3131392c33362c35362c39372c35312c3130322c39392c35342c35302c35372c34352c39372c3130302c34392c35312c34352c35322c35302c3130312c3130322c34352c39372c35302c3130302c35302c34352c3130322c35302c34392c39392c3130312c39382c35322c39392c34392c39382c3130312c35352c34302c302c3134382c3135312c3230352c3231332c31332c33352c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39392c35362c39392c39382c3130312c3130322c34382c34352c34392c34382c39392c35312c34352c35322c39382c35362c39392c34352c35362c35302c34392c35372c34352c3130312c34392c35362c35302c35312c3130302c34392c34382c35372c39392c3130312c35352c34302c302c3134382c3135312c3230352c3231332c31332c33352c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3134382c3135312c3230352c3231332c31332c33352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c34302c302c3134382c3135312c3230352c3231332c31332c33352c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c33392c302c3134382c3135312c3230352c3231332c31332c33352c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3134382c3135312c3230352c3231332c31332c33352c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3134382c3135312c3230352c3231332c31332c33352c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3134382c3135312c3230352c3231332c31332c34332c362c35342c38382c39302c3132312c35362c37382c312c34302c302c3134382c3135312c3230352c3231332c31332c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3134382c3135312c3230352c3231332c31332c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3134382c3135312c3230352c3231332c31332c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3134382c3135312c3230352c3231332c31332c34332c362c38322c37382c3131312c37312c37382c3130372c312c34302c302c3134382c3135312c3230352c3231332c31332c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3134382c3135312c3230352c3231332c31332c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3134382c3135312c3230352c3231332c31332c34382c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3134382c3135312c3230352c3231332c31332c34332c362c3130312c3131312c35342c37312c37302c3131342c312c34302c302c3134382c3135312c3230352c3231332c31332c35322c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3134382c3135312c3230352c3231332c31332c35322c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3134382c3135312c3230352c3231332c31332c35322c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3134382c3135312c3230352c3231332c31332c33352c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3134382c3135312c3230352c3231332c31332c33352c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3134382c3135312c3230352c3231332c31332c33352c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3134382c3135312c3230352c3231332c31332c33352c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3134382c3135312c3230352c3231332c31332c35392c332c3131382c312c322c3130352c3130302c3131392c362c38322c37382c3131312c37312c37382c3130372c3131382c312c322c3130352c3130302c3131392c362c35342c38382c39302c3132312c35362c37382c3131382c312c322c3130352c3130302c3131392c362c3130312c3131312c35342c37312c37302c3131342c33392c302c3134382c3135312c3230352c3231332c31332c33352c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3134382c3135312c3230352c3231332c31332c36332c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c3130322c39382c3130312c35312c35342c35322c39382c35372c34352c3130322c35312c35352c35352c34352c35322c34382c3130312c3130322c34352c35372c3130302c3130312c3130302c34352c35362c39382c35362c39372c39392c35352c39382c35352c35332c34382c34392c3130322c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c39382c35312c35332c35342c3130322c35302c35312c39382c34352c34392c35312c34392c34382c34352c35322c35352c35372c35302c34352c39372c34392c35312c34392c34352c3130322c39372c35352c35332c3130302c35322c35362c35362c35322c3130322c35352c35302c3131382c322c322c3130352c3130302c3131392c33362c39372c35302c3130322c35362c35312c35352c35302c34382c34352c34392c3130302c35352c35332c34352c35322c3130312c35322c39382c34352c35372c39382c35342c35332c34352c39372c39372c35312c35302c34382c3130312c39382c39372c35362c39372c3130322c3130322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3136312c3134382c3135312c3230352c3231332c31332c32302c312c3136312c3134382c3135312c3230352c3231332c31332c32352c312c3136312c3134382c3135312c3230352c3231332c31332c36372c312c3136312c3134382c3135312c3230352c3231332c31332c36382c312c3136382c3134382c3135312c3230352c3231332c31332c36392c312c3132322c302c302c302c302c3130322c3232302c3131302c3134342c3136382c3134382c3135312c3230352c3231332c31332c37302c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c35302c3130372c37322c3130352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c38362c3130312c37332c3131302c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3131372c3131312c38312c37332c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c312c3235352c3136342c3233322c3235342c31322c302c3136312c3133302c3137362c3139342c3135342c31302c302c312c312c3133302c3137362c3139342c3135342c31302c302c3136312c3139342c3133322c3138362c3231322c342c302c312c312c3135372c3230352c3235352c3233382c362c302c3136312c3139302c3235342c3234342c3134392c312c302c312c312c3133312c3234322c3138332c3235342c352c302c3136312c3135372c3230352c3235352c3233382c362c302c312c312c3230362c3232362c3230322c3233302c342c302c3136382c3234322c3233352c3231362c3138362c312c302c312c3131392c33362c35332c39392c35362c39392c39382c3130312c3130322c34382c34352c34392c34382c39392c35312c34352c35322c39382c35362c39392c34352c35362c35302c34392c35372c34352c3130312c34392c35362c35302c35312c3130302c34392c34382c35372c39392c3130312c35352c312c3139342c3133322c3138362c3231322c342c302c3136312c3133312c3234322c3138332c3235342c352c302c312c312c3234392c3233382c3230392c3235322c322c302c3136312c3136312c3231362c3139342c3233382c31352c302c312c312c3230382c3234312c3138332c3233312c312c302c3136312c3235352c3136342c3233322c3235342c31322c302c312c312c3234322c3233352c3231362c3138362c312c302c3136312c3139362c3139372c3133362c3139372c31352c302c312c312c3139302c3235342c3234342c3134392c312c302c3136312c3134382c3135312c3230352c3231332c31332c312c312c31332c3230382c3234312c3138332c3233312c312c312c302c312c3136312c3231362c3139342c3233382c31352c312c302c312c3139342c3133322c3138362c3231322c342c312c302c312c3133312c3234322c3138332c3235342c352c312c302c312c3134382c3135312c3230352c3231332c31332c342c312c312c32302c312c32352c312c36372c342c3133302c3137362c3139342c3135342c31302c312c302c312c3231332c3134302c3135352c3139312c31352c312c302c312c3139362c3139372c3133362c3139372c31352c312c302c312c3234322c3233352c3231362c3138362c312c312c302c312c3234392c3233382c3230392c3235322c322c312c302c312c3135372c3230352c3235352c3233382c362c312c302c312c3139302c3235342c3234342c3134392c312c312c302c312c3235352c3136342c3233322c3235342c31322c312c302c315d2c2264617461626173655f726f775f636f6c6c616273223a7b2262333536663233622d313331302d343739322d613133312d666137356434383834663732223a5b312c32382c3230382c3135312c3233362c3231392c392c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3230382c3135312c3233362c3231392c392c302c322c3130352c3130302c312c3131392c33362c39382c35312c35332c35342c3130322c35302c35312c39382c34352c34392c35312c34392c34382c34352c35322c35352c35372c35302c34352c39372c34392c35312c34392c34352c3130322c39372c35352c35332c3130302c35322c35362c35362c35322c3130322c35352c35302c34302c302c3230382c3135312c3233362c3231392c392c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39392c35362c39392c39382c3130312c3130322c34382c34352c34392c34382c39392c35312c34352c35322c39382c35362c39392c34352c35362c35302c34392c35372c34352c3130312c34392c35362c35302c35312c3130302c34392c34382c35372c39392c3130312c35352c34302c302c3230382c3135312c3233362c3231392c392c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3230382c3135312c3233362c3231392c392c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3230382c3135312c3233362c3231392c392c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c33332c302c3230382c3135312c3233362c3231392c392c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3230382c3135312c3233362c3231392c392c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3230382c3135312c3233362c3231392c392c382c312c33392c302c3230382c3135312c3233362c3231392c392c392c362c38322c37382c3131312c37312c37382c3130372c312c34302c302c3230382c3135312c3233362c3231392c392c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134302c34302c302c3230382c3135312c3233362c3231392c392c31312c342c3130302c39372c3131362c39372c312c3131392c342c3131342c3131312c3131392c35302c34302c302c3230382c3135312c3233362c3231392c392c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3230382c3135312c3233362c3231392c392c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134302c3136312c3230382c3135312c3233362c3231392c392c31302c312c33392c302c3230382c3135312c3233362c3231392c392c392c362c35342c38382c39302c3132312c35362c37382c312c34302c302c3230382c3135312c3233362c3231392c392c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134332c34302c302c3230382c3135312c3233362c3231392c392c31372c342c3130302c39372c3131362c39372c312c3131392c342c38362c3130312c37332c3131302c34302c302c3230382c3135312c3233362c3231392c392c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3230382c3135312c3233362c3231392c392c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134332c3136382c3230382c3135312c3233362c3231392c392c31362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c33392c302c3230382c3135312c3233362c3231392c392c392c362c3130312c3131312c35342c37312c37302c3131342c312c34302c302c3230382c3135312c3233362c3231392c392c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c34302c302c3230382c3135312c3233362c3231392c392c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3230382c3135312c3233362c3231392c392c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3230382c3135312c3233362c3231392c392c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c312c3230382c3135312c3233362c3231392c392c332c382c312c31302c312c31362c315d2c2266626533363462392d663337372d343065662d396465642d386238616337623735303166223a5b312c33312c3135302c3234342c3137382c3232332c312c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3135302c3234342c3137382c3232332c312c302c322c3130352c3130302c312c3131392c33362c3130322c39382c3130312c35312c35342c35322c39382c35372c34352c3130322c35312c35352c35352c34352c35322c34382c3130312c3130322c34352c35372c3130302c3130312c3130302c34352c35362c39382c35362c39372c39392c35352c39382c35352c35332c34382c34392c3130322c34302c302c3135302c3234342c3137382c3232332c312c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39392c35362c39392c39382c3130312c3130322c34382c34352c34392c34382c39392c35312c34352c35322c39382c35362c39392c34352c35362c35302c34392c35372c34352c3130312c34392c35362c35302c35312c3130302c34392c34382c35372c39392c3130312c35352c34302c302c3135302c3234342c3137382c3232332c312c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3135302c3234342c3137382c3232332c312c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3135302c3234342c3137382c3232332c312c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c33332c302c3135302c3234342c3137382c3232332c312c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3135302c3234342c3137382c3232332c312c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3135302c3234342c3137382c3232332c312c382c312c33392c302c3135302c3234342c3137382c3232332c312c392c362c38322c37382c3131312c37312c37382c3130372c312c34302c302c3135302c3234342c3137382c3232332c312c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3133352c34302c302c3135302c3234342c3137382c3232332c312c31312c342c3130302c39372c3131362c39372c312c3131392c342c3131342c3131312c3131392c34392c34302c302c3135302c3234342c3137382c3232332c312c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3135302c3234342c3137382c3232332c312c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3133352c3136312c3135302c3234342c3137382c3232332c312c31302c312c33392c302c3135302c3234342c3137382c3232332c312c392c362c35342c38382c39302c3132312c35362c37382c312c34302c302c3135302c3234342c3137382c3232332c312c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3133352c34302c302c3135302c3234342c3137382c3232332c312c31372c342c3130302c39372c3131362c39372c312c3131392c342c3131372c3131312c38312c37332c34302c302c3135302c3234342c3137382c3232332c312c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3135302c3234342c3137382c3232332c312c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3133352c3136382c3135302c3234342c3137382c3232332c312c31362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134352c33392c302c3135302c3234342c3137382c3232332c312c392c362c3130312c3131312c35342c37312c37302c3131342c312c34302c302c3135302c3234342c3137382c3232332c312c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134352c34302c302c3135302c3234342c3137382c3232332c312c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3135302c3234342c3137382c3232332c312c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3135302c3234342c3137382c3232332c312c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134352c34302c302c3135302c3234342c3137382c3232332c312c312c33362c39392c35342c35372c3130312c35312c35332c34392c3130322c34352c39392c39382c35332c39392c34352c35332c35362c34382c35362c34352c35372c39392c35322c3130322c34352c3130322c35322c35342c35372c35352c34382c34392c3130322c35352c35342c39372c35362c312c3131392c342c3234302c3135392c3134372c3134302c33332c302c3135302c3234342c3137382c3232332c312c312c33362c3130312c3130302c39372c35352c34382c35342c3130302c39382c34352c3130312c39382c39382c34392c34352c35332c3130322c39372c35332c34352c39382c34382c35352c35362c34352c35342c39392c35322c39372c35362c35312c35312c35302c35342c34392c35352c35352c322c3136382c3135302c3234342c3137382c3232332c312c33302c312c3132312c312c3135302c3234342c3137382c3232332c312c342c382c312c31302c312c31362c312c32392c325d2c2261326638333732302d316437352d346534622d396236352d616133323065626138616666223a5b312c32382c3137342c3234332c3137322c3136372c362c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3137342c3234332c3137322c3136372c362c302c322c3130352c3130302c312c3131392c33362c39372c35302c3130322c35362c35312c35352c35302c34382c34352c34392c3130302c35352c35332c34352c35322c3130312c35322c39382c34352c35372c39382c35342c35332c34352c39372c39372c35312c35302c34382c3130312c39382c39372c35362c39372c3130322c3130322c34302c302c3137342c3234332c3137322c3136372c362c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35332c39392c35362c39392c39382c3130312c3130322c34382c34352c34392c34382c39392c35312c34352c35322c39382c35362c39392c34352c35362c35302c34392c35372c34352c3130312c34392c35362c35302c35312c3130302c34392c34382c35372c39392c3130312c35352c34302c302c3137342c3234332c3137322c3136372c362c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3137342c3234332c3137322c3136372c362c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3137342c3234332c3137322c3136372c362c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c39322c33332c302c3137342c3234332c3137322c3136372c362c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3137342c3234332c3137322c3136372c362c302c352c39392c3130312c3130382c3130382c3131352c312c3136312c3137342c3234332c3137322c3136372c362c382c312c33392c302c3137342c3234332c3137322c3136372c362c392c362c38322c37382c3131312c37312c37382c3130372c312c34302c302c3137342c3234332c3137322c3136372c362c31312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134322c34302c302c3137342c3234332c3137322c3136372c362c31312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3137342c3234332c3137322c3136372c362c31312c342c3130302c39372c3131362c39372c312c3131392c342c3131342c3131312c3131392c35312c34302c302c3137342c3234332c3137322c3136372c362c31312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134322c3136312c3137342c3234332c3137322c3136372c362c31302c312c33392c302c3137342c3234332c3137322c3136372c362c392c362c35342c38382c39302c3132312c35362c37382c312c34302c302c3137342c3234332c3137322c3136372c362c31372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134342c34302c302c3137342c3234332c3137322c3136372c362c31372c342c3130302c39372c3131362c39372c312c3131392c342c35302c3130372c37322c3130352c34302c302c3137342c3234332c3137322c3136372c362c31372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3137342c3234332c3137322c3136372c362c31372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134342c3136382c3137342c3234332c3137322c3136372c362c31362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c33392c302c3137342c3234332c3137322c3136372c362c392c362c3130312c3131312c35342c37312c37302c3131342c312c34302c302c3137342c3234332c3137322c3136372c362c32332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c34302c302c3137342c3234332c3137322c3136372c362c32332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3137342c3234332c3137322c3136372c362c32332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3137342c3234332c3137322c3136372c362c32332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3232302c3131302c3134362c312c3137342c3234332c3137322c3136372c362c332c382c312c31302c312c31362c315d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b2230623735393866322d393232382d356130382d626635332d356437366235383261346532223a5b322c32362c3139362c3136312c3233342c3139312c362c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c3131312c39392c3131372c3130392c3130312c3131302c3131362c312c33392c302c3139362c3136312c3233342c3139312c362c302c362c39382c3130382c3131312c39392c3130372c3131352c312c33392c302c3139362c3136312c3233342c3139312c362c302c342c3130392c3130312c3131362c39372c312c33392c302c3139362c3136312c3233342c3139312c362c322c31322c39392c3130342c3130352c3130382c3130302c3131342c3130312c3131302c39352c3130392c39372c3131322c312c33392c302c3139362c3136312c3233342c3139312c362c322c382c3131362c3130312c3132302c3131362c39352c3130392c39372c3131322c312c34302c302c3139362c3136312c3233342c3139312c362c302c372c3131322c39372c3130332c3130312c39352c3130352c3130302c312c3131392c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c33392c302c3139362c3136312c3233342c3139312c362c312c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c312c34302c302c3139362c3136312c3233342c3139312c362c362c322c3130352c3130302c312c3131392c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c34302c302c3139362c3136312c3233342c3139312c362c362c322c3131362c3132312c312c3131392c342c3131322c39372c3130332c3130312c34302c302c3139362c3136312c3233342c3139312c362c362c362c3131322c39372c3131342c3130312c3131302c3131362c312c3131392c302c34302c302c3139362c3136312c3233342c3139312c362c362c382c39392c3130342c3130352c3130382c3130302c3131342c3130312c3131302c312c3131392c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c34302c302c3139362c3136312c3233342c3139312c362c362c342c3130302c39372c3131362c39372c312c3131392c322c3132332c3132352c34302c302c3139362c3136312c3233342c3139312c362c362c31312c3130312c3132302c3131362c3130312c3131342c3131302c39372c3130382c39352c3130352c3130302c312c3132362c34302c302c3139362c3136312c3233342c3139312c362c362c31332c3130312c3132302c3131362c3130312c3131342c3131302c39372c3130382c39352c3131362c3132312c3131322c3130312c312c3132362c33392c302c3139362c3136312c3233342c3139312c362c332c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c302c33392c302c3139362c3136312c3233342c3139312c362c312c31302c3130322c39352c35302c3131332c3131382c38342c34392c37382c3130382c3131392c312c34302c302c3139362c3136312c3233342c3139312c362c31352c322c3130352c3130302c312c3131392c31302c3130322c39352c35302c3131332c3131382c38342c34392c37382c3130382c3131392c34302c302c3139362c3136312c3233342c3139312c362c31352c322c3131362c3132312c312c3131392c392c3131322c39372c3131342c39372c3130332c3131342c39372c3131322c3130342c34302c302c3139362c3136312c3233342c3139312c362c31352c362c3131322c39372c3131342c3130312c3131302c3131362c312c3131392c33362c35332c35332c35342c35352c35342c34392c35362c39382c34352c3130312c35362c35322c35342c34352c35332c3130322c39372c39372c34352c35362c35352c39392c34392c34352c35362c35322c35362c35302c35352c3130302c34392c35372c3130302c39382c35312c35302c34302c302c3139362c3136312c3233342c3139312c362c31352c382c39392c3130342c3130352c3130382c3130302c3131342c3130312c3131302c312c3131392c31302c37362c3131342c37352c3130382c35372c3130322c3130302c36372c3130352c36372c34302c302c3139362c3136312c3233342c3139312c362c31352c342c3130302c39372c3131362c39372c312c3131392c322c3132332c3132352c34302c302c3139362c3136312c3233342c3139312c362c31352c31312c3130312c3132302c3131362c3130312c3131342c3131302c39372c3130382c39352c3130352c3130302c312c3131392c31302c37352c35322c35362c3130302c36372c3130312c39372c37342c38322c36372c34302c302c3139362c3136312c3233342c3139312c362c31352c31332c3130312c3132302c3131362c3130312c3131342c3131302c39372c3130382c39352c3131362c3132312c3131322c3130312c312c3131392c342c3131362c3130312c3132302c3131362c33392c302c3139362c3136312c3233342c3139312c362c332c31302c37362c3131342c37352c3130382c35372c3130322c3130302c36372c3130352c36372c302c382c302c3139362c3136312c3233342c3139312c362c31342c312c3131392c31302c3130322c39352c35302c3131332c3131382c38342c34392c37382c3130382c3131392c33392c302c3139362c3136312c3233342c3139312c362c342c31302c37352c35322c35362c3130302c36372c3130312c39372c37342c38322c36372c322c322c3134332c3231322c3230302c3137392c342c302c312c302c3139362c3136312c3233342c3139312c362c32352c312c3133322c3134332c3231322c3230302c3137392c342c302c31382c3131342c3131312c3131392c34392c33322c3130302c39372c3131362c39372c33322c39392c3131312c3131302c3131362c3130312c3131302c3131362c3131352c312c3134332c3231322c3230302c3137392c342c312c302c315d7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2238613366633632392d616431332d343265662d613264322d663231636562346331626537225d2c2264617461626173655f72656c6174696f6e73223a7b2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238222c2235633863626566302d313063332d346238632d383231392d653138323364313039636537223a2238613366633632392d616431332d343265662d613264322d663231636562346331626537227d7d\";\n\npub const DB_REL_SELF_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🥵\"\n    },\n    \"name\": \"self_ref_db\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"18d72589-80d7-4041-9342-5d572facb7c9\",\n    \"created_at\": 1726536202,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1726536217\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1725353187,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1725353187\n    },\n    {\n      \"icon\": null,\n      \"name\": \"somespac\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1725353187638}\",\n      \"layout\": 0,\n      \"view_id\": \"e53a1ec9-5a29-4b3c-9c04-4d33eed40561\",\n      \"created_at\": 1726536202,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726536202\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🥵\"\n      },\n      \"name\": \"self_ref_db\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"18d72589-80d7-4041-9342-5d572facb7c9\",\n      \"created_at\": 1726536202,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726536217\n    }\n  ]\n}\n\"#;\npub const DB_REL_SELF_HEX: &str = \"7b2264617461626173655f636f6c6c6162223a5b322c352c3233342c3234312c3134372c3234352c362c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3233342c3234312c3134372c3234352c362c302c322c3130352c3130302c312c33392c302c3233342c3234312c3134372c3234352c362c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3233342c3234312c3134372c3234352c362c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3233342c3234312c3134372c3234352c362c302c352c3130392c3130312c3131362c39372c3131352c312c3130322c3235352c3232382c3232352c37362c302c3136382c3233342c3234312c3134372c3234352c362c312c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c34302c302c3233342c3234312c3134372c3234352c362c342c332c3130352c3130352c3130302c312c3131392c33362c34392c35362c3130302c35352c35302c35332c35362c35372c34352c35362c34382c3130302c35352c34352c35322c34382c35322c34392c34352c35372c35312c35322c35302c34352c35332c3130302c35332c35352c35302c3130322c39372c39392c39382c35352c39392c35372c33392c302c3233342c3234312c3134372c3234352c362c322c362c37362c3130332c3130342c37362c39392c3132322c312c34302c302c3235352c3232382c3232352c37362c322c322c3130352c3130302c312c3131392c362c37362c3130332c3130342c37362c39392c3132322c34302c302c3235352c3232382c3232352c37362c322c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3235352c3232382c3232352c37362c322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c34302c302c3235352c3232382c3232352c37362c322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c34302c302c3235352c3232382c3232352c37362c322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3235352c3232382c3232352c37362c322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3235352c3232382c3232352c37362c322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235352c3232382c3232352c37362c392c312c34382c312c34302c302c3235352c3232382c3232352c37362c31302c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3233342c3234312c3134372c3234352c362c322c362c3131322c3131302c37322c3130352c38322c39352c312c34302c302c3235352c3232382c3232352c37362c31322c322c3130352c3130302c312c3131392c362c3131322c3131302c37322c3130352c38322c39352c34302c302c3235352c3232382c3232352c37362c31322c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3235352c3232382c3232352c37362c31322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c33332c302c3235352c3232382c3232352c37362c31322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3235352c3232382c3232352c37362c31322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235352c3232382c3232352c37362c31322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3235352c3232382c3232352c37362c31322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235352c3232382c3232352c37362c31392c312c35312c312c33332c302c3235352c3232382c3232352c37362c32302c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3233342c3234312c3134372c3234352c362c322c362c3130332c35332c35322c37362c3131302c3131332c312c34302c302c3235352c3232382c3232352c37362c32322c322c3130352c3130302c312c3131392c362c3130332c35332c35322c37362c3131302c3131332c34302c302c3235352c3232382c3232352c37362c32322c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3235352c3232382c3232352c37362c32322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c34302c302c3235352c3232382c3232352c37362c32322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c34302c302c3235352c3232382c3232352c37362c32322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3235352c3232382c3232352c37362c32322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3235352c3232382c3232352c37362c32322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235352c3232382c3232352c37362c32392c312c35332c312c33392c302c3233342c3234312c3134372c3234352c362c332c33362c34392c35362c3130302c35352c35302c35332c35362c35372c34352c35362c34382c3130302c35352c34352c35322c34382c35322c34392c34352c35372c35312c35322c35302c34352c35332c3130302c35332c35352c35302c3130322c39372c39392c39382c35352c39392c35372c312c34302c302c3235352c3232382c3232352c37362c33312c322c3130352c3130302c312c3131392c33362c34392c35362c3130302c35352c35302c35332c35362c35372c34352c35362c34382c3130302c35352c34352c35322c34382c35322c34392c34352c35372c35312c35322c35302c34352c35332c3130302c35332c35352c35302c3130322c39372c39392c39382c35352c39392c35372c34302c302c3235352c3232382c3232352c37362c33312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c34302c302c3235352c3232382c3232352c37362c33312c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3235352c3232382c3232352c37362c33312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c33332c302c3235352c3232382c3232352c37362c33312c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c33392c302c3235352c3232382c3232352c37362c33312c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3235352c3232382c3232352c37362c33312c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3235352c3232382c3232352c37362c33312c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3235352c3232382c3232352c37362c33392c362c37362c3130332c3130342c37362c39392c3132322c312c34302c302c3235352c3232382c3232352c37362c34302c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3235352c3232382c3232352c37362c34302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3235352c3232382c3232352c37362c34302c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3235352c3232382c3232352c37362c33392c362c3131322c3131302c37322c3130352c38322c39352c312c34302c302c3235352c3232382c3232352c37362c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3235352c3232382c3232352c37362c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3235352c3232382c3232352c37362c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3235352c3232382c3232352c37362c33392c362c3130332c35332c35322c37362c3131302c3131332c312c34302c302c3235352c3232382c3232352c37362c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3235352c3232382c3232352c37362c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3235352c3232382c3232352c37362c34382c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3235352c3232382c3232352c37362c33312c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3235352c3232382c3232352c37362c33312c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3235352c3232382c3232352c37362c33312c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3235352c3232382c3232352c37362c33312c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235352c3232382c3232352c37362c35352c332c3131382c312c322c3130352c3130302c3131392c362c37362c3130332c3130342c37362c39392c3132322c3131382c312c322c3130352c3130302c3131392c362c3131322c3131302c37322c3130352c38322c39352c3131382c312c322c3130352c3130302c3131392c362c3130332c35332c35322c37362c3131302c3131332c33392c302c3235352c3232382c3232352c37362c33312c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3235352c3232382c3232352c37362c35392c332c3131382c322c322c3130352c3130302c3131392c33362c35352c35312c39382c3130312c3130302c35362c3130312c39372c34352c35372c35302c35312c34382c34352c35322c3130322c39382c35332c34352c35372c35352c35362c34392c34352c34392c34382c35302c35332c35312c35342c3130302c35322c3130302c39382c35322c35372c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35362c35372c35342c35362c39382c35352c3130302c39392c34352c35352c35322c35352c39372c34352c35322c39372c35342c34382c34352c39372c3130312c35332c3130312c34352c35342c34392c3130312c35322c3130302c35322c35372c35302c3130312c39372c3130302c35332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35362c35322c3130322c35322c35322c35332c35322c34382c34352c35352c35332c3130322c3130302c34352c35322c35342c35332c35322c34352c39372c35362c3130322c3130302c34352c34392c35322c3130322c35362c35352c3130302c34392c35322c35372c35352c35302c35312c3136312c3235352c3232382c3232352c37362c31362c312c3136312c3235352c3232382c3232352c37362c32312c312c3136312c3235352c3232382c3232352c37362c36332c312c3136312c3235352c3232382c3232352c37362c36342c312c3136382c3235352c3232382c3232352c37362c36352c312c3132322c302c302c302c302c3130322c3233322c3231382c33312c3136382c3235352c3232382c3232352c37362c36362c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c34392c38372c3131302c36372c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c37392c34382c37322c38352c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c37312c37372c3132312c38342c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c3136312c3235352c3232382c3232352c37362c33362c312c3133362c3235352c3232382c3232352c37362c35382c312c3131382c312c322c3130352c3130302c3131392c362c3131302c3130332c37372c38342c37392c3132312c33392c302c3235352c3232382c3232352c37362c33392c362c3131302c3130332c37372c38342c37392c3132312c312c34302c302c3235352c3232382c3232352c37362c37312c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3233342c3234312c3134372c3234352c362c322c362c3131302c3130332c37372c38342c37392c3132312c312c34302c302c3235352c3232382c3232352c37362c37332c322c3130352c3130302c312c3131392c362c3131302c3130332c37372c38342c37392c3132312c33332c302c3235352c3232382c3232352c37362c37332c342c3131302c39372c3130392c3130312c312c34302c302c3235352c3232382c3232352c37362c37332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33342c33332c302c3235352c3232382c3232352c37362c37332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3235352c3232382c3232352c37362c37332c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c33332c302c3235352c3232382c3232352c37362c37332c322c3131362c3132312c312c33392c302c3235352c3232382c3232352c37362c37332c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3235352c3232382c3232352c37362c38302c312c34382c312c34302c302c3235352c3232382c3232352c37362c38312c342c3130302c39372c3131362c39372c312c3131392c302c3136312c3235352c3232382c3232352c37362c37372c312c3136312c3235352c3232382c3232352c37362c37352c312c3136312c3235352c3232382c3232352c37362c38332c312c3136312c3235352c3232382c3232352c37362c38342c312c3136312c3235352c3232382c3232352c37362c38352c312c3136382c3235352c3232382c3232352c37362c37392c312c3132322c302c302c302c302c302c302c302c31302c33392c302c3235352c3232382c3232352c37362c38302c322c34392c34382c312c33332c302c3235352c3232382c3232352c37362c38392c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3136312c3235352c3232382c3232352c37362c38372c312c34302c302c3235352c3232382c3232352c37362c38312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c302c3136382c3235352c3232382c3232352c37362c36392c312c3132322c302c302c302c302c3130322c3233322c3231382c33382c3136312c3235352c3232382c3232352c37362c39312c312c3136312c3235352c3232382c3232352c37362c38362c312c3136312c3235352c3232382c3232352c37362c39342c312c3136312c3235352c3232382c3232352c37362c39352c312c3136312c3235352c3232382c3232352c37362c39362c312c3136312c3235352c3232382c3232352c37362c39372c312c3136312c3235352c3232382c3232352c37362c39382c312c3136312c3235352c3232382c3232352c37362c39392c312c3136312c3235352c3232382c3232352c37362c3130302c312c3136382c3235352c3232382c3232352c37362c3130312c312c3131392c332c3131342c3130312c3130382c3136382c3235352c3232382c3232352c37362c3130322c312c3132322c302c302c302c302c3130322c3233322c3231382c34362c3136382c3235352c3232382c3232352c37362c39302c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c322c3233342c3234312c3134372c3234352c362c312c312c312c3235352c3232382c3232352c37362c31312c31362c312c32312c312c33362c312c36332c342c36392c312c37352c312c37372c312c37392c312c38332c352c39302c322c39342c395d2c2264617461626173655f726f775f636f6c6c616273223a7b2238346634343534302d373566642d343635342d613866642d313466383764313439373233223a5b322c32392c3231382c3135342c3233302c3138342c31322c302c3136312c3235332c3233302c3133362c3234382c382c382c312c33392c302c3235332c3233302c3133362c3234382c382c392c362c37362c3130332c3130342c37362c39392c3132322c312c34302c302c3231382c3135342c3233302c3138342c31322c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c32352c34302c302c3231382c3135342c3233302c3138342c31322c312c342c3130302c39372c3131362c39372c312c3131392c312c39392c34302c302c3231382c3135342c3233302c3138342c31322c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3231382c3135342c3233302c3138342c31322c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c32352c3136312c3231382c3135342c3233302c3138342c31322c302c312c33392c302c3235332c3233302c3133362c3234382c382c392c362c3131322c3131302c37322c3130352c38322c39352c312c34302c302c3231382c3135342c3233302c3138342c31322c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33312c34302c302c3231382c3135342c3233302c3138342c31322c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3231382c3135342c3233302c3138342c31322c372c342c3130302c39372c3131362c39372c312c3131392c342c34392c38372c3131302c36372c34302c302c3231382c3135342c3233302c3138342c31322c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c33312c3136312c3231382c3135342c3233302c3138342c31322c362c312c33392c302c3235332c3233302c3133362c3234382c382c392c362c3130332c35332c35322c37362c3131302c3131332c312c34302c302c3231382c3135342c3233302c3138342c31322c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33332c34302c302c3231382c3135342c3233302c3138342c31322c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3231382c3135342c3233302c3138342c31322c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3231382c3135342c3233302c3138342c31322c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c33332c3136312c3231382c3135342c3233302c3138342c31322c31322c312c33392c302c3235332c3233302c3133362c3234382c382c392c362c3131302c3130332c37372c38342c37392c3132312c312c34302c302c3231382c3135342c3233302c3138342c31322c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c35342c33332c302c3231382c3135342c3233302c3138342c31322c31392c342c3130302c39372c3131362c39372c312c302c312c34302c302c3231382c3135342c3233302c3138342c31322c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33332c302c3231382c3135342c3233302c3138342c31322c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136382c3231382c3135342c3233302c3138342c31322c31382c312c3132322c302c302c302c302c3130322c3233322c3231382c35362c3136372c3231382c3135342c3233302c3138342c31322c32312c302c382c302c3231382c3135342c3233302c3138342c31322c32362c322c3131392c33362c35362c35372c35342c35362c39382c35352c3130302c39392c34352c35352c35322c35352c39372c34352c35322c39372c35342c34382c34352c39372c3130312c35332c3130312c34352c35342c34392c3130312c35322c3130302c35322c35372c35302c3130312c39372c3130302c35332c3131392c33362c35362c35322c3130322c35322c35322c35332c35322c34382c34352c35352c35332c3130322c3130302c34352c35322c35342c35332c35322c34352c39372c35362c3130322c3130302c34352c34392c35322c3130322c35362c35352c3130302c34392c35322c35372c35352c35302c35312c3136382c3231382c3135342c3233302c3138342c31322c32342c312c3132322c302c302c302c302c3130322c3233322c3231382c35362c31302c3235332c3233302c3133362c3234382c382c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3235332c3233302c3133362c3234382c382c302c322c3130352c3130302c312c3131392c33362c35362c35322c3130322c35322c35322c35332c35322c34382c34352c35352c35332c3130322c3130302c34352c35322c35342c35332c35322c34352c39372c35362c3130322c3130302c34352c34392c35322c3130322c35362c35352c3130302c34392c35322c35372c35352c35302c35312c34302c302c3235332c3233302c3133362c3234382c382c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c34302c302c3235332c3233302c3133362c3234382c382c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3235332c3233302c3133362c3234382c382c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3235332c3233302c3133362c3234382c382c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c33332c302c3235332c3233302c3133362c3234382c382c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3235332c3233302c3133362c3234382c382c302c352c39392c3130312c3130382c3130382c3131352c312c322c3235332c3233302c3133362c3234382c382c312c382c312c3231382c3135342c3233302c3138342c31322c362c302c312c362c312c31322c312c31382c312c32312c322c32342c315d2c2237336265643865612d393233302d346662352d393738312d313032353336643464623439223a5b322c32392c3136312c3133312c3135352c3235332c31332c302c3136312c3139332c3232392c3133372c3134322c312c382c312c33392c302c3139332c3232392c3133372c3134322c312c392c362c37362c3130332c3130342c37362c39392c3132322c312c34302c302c3136312c3133312c3135352c3235332c31332c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c32322c34302c302c3136312c3133312c3135352c3235332c31332c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3136312c3133312c3135352c3235332c31332c312c342c3130302c39372c3131362c39372c312c3131392c312c3130382c34302c302c3136312c3133312c3135352c3235332c31332c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c32322c3136312c3136312c3133312c3135352c3235332c31332c302c312c33392c302c3139332c3232392c3133372c3134322c312c392c362c3131322c3131302c37322c3130352c38322c39352c312c34302c302c3136312c3133312c3135352c3235332c31332c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c32382c34302c302c3136312c3133312c3135352c3235332c31332c372c342c3130302c39372c3131362c39372c312c3131392c342c37312c37372c3132312c38342c34302c302c3136312c3133312c3135352c3235332c31332c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3136312c3133312c3135352c3235332c31332c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c32382c3136312c3136312c3133312c3135352c3235332c31332c362c312c33392c302c3139332c3232392c3133372c3134322c312c392c362c3130332c35332c35322c37362c3131302c3131332c312c34302c302c3136312c3133312c3135352c3235332c31332c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33322c34302c302c3136312c3133312c3135352c3235332c31332c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3136312c3133312c3135352c3235332c31332c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3136312c3133312c3135352c3235332c31332c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c33322c3136312c3136312c3133312c3135352c3235332c31332c31322c312c33392c302c3139332c3232392c3133372c3134322c312c392c362c3131302c3130332c37372c38342c37392c3132312c312c34302c302c3136312c3133312c3135352c3235332c31332c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c34372c34302c302c3136312c3133312c3135352c3235332c31332c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33332c302c3136312c3133312c3135352c3235332c31332c31392c342c3130302c39372c3131362c39372c312c302c312c33332c302c3136312c3133312c3135352c3235332c31332c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136382c3136312c3133312c3135352c3235332c31332c31382c312c3132322c302c302c302c302c3130322c3233322c3231382c34382c3136372c3136312c3133312c3135352c3235332c31332c32322c302c382c302c3136312c3133312c3135352c3235332c31332c32362c322c3131392c33362c35362c35372c35342c35362c39382c35352c3130302c39392c34352c35352c35322c35352c39372c34352c35322c39372c35342c34382c34352c39372c3130312c35332c3130312c34352c35342c34392c3130312c35322c3130302c35322c35372c35302c3130312c39372c3130302c35332c3131392c33362c35352c35312c39382c3130312c3130302c35362c3130312c39372c34352c35372c35302c35312c34382c34352c35322c3130322c39382c35332c34352c35372c35352c35362c34392c34352c34392c34382c35302c35332c35312c35342c3130302c35322c3130302c39382c35322c35372c3136382c3136312c3133312c3135352c3235332c31332c32342c312c3132322c302c302c302c302c3130322c3233322c3231382c34382c31302c3139332c3232392c3133372c3134322c312c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139332c3232392c3133372c3134322c312c302c322c3130352c3130302c312c3131392c33362c35352c35312c39382c3130312c3130302c35362c3130312c39372c34352c35372c35302c35312c34382c34352c35322c3130322c39382c35332c34352c35372c35352c35362c34392c34352c34392c34382c35302c35332c35312c35342c3130302c35322c3130302c39382c35322c35372c34302c302c3139332c3232392c3133372c3134322c312c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c34302c302c3139332c3232392c3133372c3134322c312c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139332c3232392c3133372c3134322c312c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139332c3232392c3133372c3134322c312c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c33332c302c3139332c3232392c3133372c3134322c312c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139332c3232392c3133372c3134322c312c302c352c39392c3130312c3130382c3130382c3131352c312c322c3139332c3232392c3133372c3134322c312c312c382c312c3136312c3133312c3135352c3235332c31332c352c302c312c362c312c31322c312c31382c312c32322c335d2c2238393638623764632d373437612d346136302d616535652d363165346434393265616435223a5b322c32352c3230322c3135302c3139352c3233362c31312c302c3136312c3133362c3232302c3136332c3139312c342c382c312c33392c302c3133362c3232302c3136332c3139312c342c392c362c37362c3130332c3130342c37362c39392c3132322c312c34302c302c3230322c3135302c3139352c3233362c31312c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c32332c34302c302c3230322c3135302c3139352c3233362c31312c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3230322c3135302c3139352c3233362c31312c312c342c3130302c39372c3131362c39372c312c3131392c312c3130342c34302c302c3230322c3135302c3139352c3233362c31312c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c32332c3136312c3230322c3135302c3139352c3233362c31312c302c312c33392c302c3133362c3232302c3136332c3139312c342c392c362c3131322c3131302c37322c3130352c38322c39352c312c34302c302c3230322c3135302c3139352c3233362c31312c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33302c34302c302c3230322c3135302c3139352c3233362c31312c372c342c3130302c39372c3131362c39372c312c3131392c342c37392c34382c37322c38352c34302c302c3230322c3135302c3139352c3233362c31312c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3230322c3135302c3139352c3233362c31312c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c33302c3136312c3230322c3135302c3139352c3233362c31312c362c312c33392c302c3133362c3232302c3136332c3139312c342c392c362c3130332c35332c35322c37362c3131302c3131332c312c34302c302c3230322c3135302c3139352c3233362c31312c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c33332c34302c302c3230322c3135302c3139352c3233362c31312c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3230322c3135302c3139352c3233362c31312c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3230322c3135302c3139352c3233362c31312c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c33332c3136382c3230322c3135302c3139352c3233362c31312c31322c312c3132322c302c302c302c302c3130322c3233322c3231382c35302c33392c302c3133362c3232302c3136332c3139312c342c392c362c3131302c3130332c37372c38342c37392c3132312c312c34302c302c3230322c3135302c3139352c3233362c31312c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c35302c33392c302c3230322c3135302c3139352c3233362c31312c31392c342c3130302c39372c3131362c39372c302c382c302c3230322c3135302c3139352c3233362c31312c32312c312c3131392c33362c35362c35322c3130322c35322c35322c35332c35322c34382c34352c35352c35332c3130322c3130302c34352c35322c35342c35332c35322c34352c39372c35362c3130322c3130302c34352c34392c35322c3130322c35362c35352c3130302c34392c35322c35372c35352c35302c35312c34302c302c3230322c3135302c3139352c3233362c31312c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c34302c302c3230322c3135302c3139352c3233362c31312c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233322c3231382c35302c31302c3133362c3232302c3136332c3139312c342c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3133362c3232302c3136332c3139312c342c302c322c3130352c3130302c312c3131392c33362c35362c35372c35342c35362c39382c35352c3130302c39392c34352c35352c35322c35352c39372c34352c35322c39372c35342c34382c34352c39372c3130312c35332c3130312c34352c35342c34392c3130312c35322c3130302c35322c35372c35302c3130312c39372c3130302c35332c34302c302c3133362c3232302c3136332c3139312c342c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35302c39392c35312c35362c34392c35342c35362c35362c34352c39382c35312c34382c35352c34352c35322c34382c35372c39382c34352c39382c35302c34392c35362c34352c35302c34382c3130312c35342c35372c39392c34382c35362c3130322c34392c34382c35342c34302c302c3133362c3232302c3136332c3139312c342c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3133362c3232302c3136332c3139312c342c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3133362c3232302c3136332c3139312c342c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233322c3231382c31302c33332c302c3133362c3232302c3136332c3139312c342c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3133362c3232302c3136332c3139312c342c302c352c39392c3130312c3130382c3130382c3131352c312c322c3133362c3232302c3136332c3139312c342c312c382c312c3230322c3135302c3139352c3233362c31312c332c302c312c362c312c31322c315d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2231386437323538392d383064372d343034312d393334322d356435373266616362376339225d2c2264617461626173655f72656c6174696f6e73223a7b2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2235633863626566302d313063332d346238632d383231392d653138323364313039636537223a2238613366633632392d616431332d343265662d613264322d663231636562346331626537222c2237323139376235322d666530642d343962382d623438662d616136323732633032393666223a2264666630393733632d393034382d346532332d393639372d303463623134633531386231222c2232613437376165652d613731372d346364302d623538392d373561333337353162393333223a2265376533613336312d326666612d346264642d383666382d653438663831316165336232222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238222c2232633338313638382d623330372d343039622d623231382d323065363963303866313036223a2231386437323538392d383064372d343034312d393334322d356435373266616362376339227d7d\";\n\npub const DOC_4_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🫗\"\n    },\n    \"name\": \"doc4\",\n    \"extra\": null,\n    \"layout\": 0,\n    \"view_id\": \"bb429fb8-3bb8-4f8c-99d8-0d8c48047efc\",\n    \"created_at\": 1726549942,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1726550431\n  },\n  \"child_views\": [\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🧈\"\n      },\n      \"name\": \"grid1\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"5ecf6aa1-d4d6-47e4-af0f-3a7a9fd8299d\",\n      \"created_at\": 1726549942,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726550404\n    }\n  ],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1726549363,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726549363\n    },\n    {\n      \"icon\": null,\n      \"name\": \"space2\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1726549363366}\",\n      \"layout\": 0,\n      \"view_id\": \"dc8bc7fe-cd63-4741-b62c-14086fd824b7\",\n      \"created_at\": 1726550230,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726550230\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🫗\"\n      },\n      \"name\": \"doc4\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"bb429fb8-3bb8-4f8c-99d8-0d8c48047efc\",\n      \"created_at\": 1726549942,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726550431\n    }\n  ]\n}\n\"#;\npub const DOC_4_DOC_STATE_HEX: &str = \"030ba9a7c6f509000100c0dba80319052700c0dba803010657487a486956012800a9a7c6f5090502696401770657487a4869562800a9a7c6f50905027479017704677269642800a9a7c6f5090506706172656e7401772439356230346266642d346166332d353937632d613332362d3936623335333833663033312800a9a7c6f50905086368696c6472656e017706777474586b572800a9a7c6f5090504646174610177657b22706172656e745f6964223a2262623432396662382d336262382d346638632d393964382d306438633438303437656663222c22766965775f6964223a2235656366366161312d643464362d343765342d616630662d336137613966643832393964227d2800a9a7c6f509050b65787465726e616c5f6964017e2800a9a7c6f509050d65787465726e616c5f74797065017e2700c0dba8030306777474586b570048c0dba8031801770657487a4869563b81e08dc7050084a9a7c6f5090401688181e08dc70500048481e08dc7050404656c6c6f2700c0dba80304064772644c5070022700c0dba80301064e39415a533101280081e08dc7050a0269640177064e39415a5331280081e08dc7050a027479017709706172616772617068280081e08dc7050a06706172656e7401772439356230346266642d346166332d353937632d613332362d393662333533383366303331280081e08dc7050a086368696c6472656e01770658704d71495f280081e08dc7050a04646174610177027b7d280081e08dc7050a0b65787465726e616c5f69640177064772644c5070280081e08dc7050a0d65787465726e616c5f74797065017704746578742700c0dba803030658704d71495f0088c0dba803180177064e39415a53312700c0dba803040679447467776e022700c0dba803010671576575506d01280081e08dc7051502696401770671576575506d280081e08dc70515027479017709706172616772617068280081e08dc7051506706172656e7401772439356230346266642d346166332d353937632d613332362d393662333533383366303331280081e08dc70515086368696c6472656e0177065a2d49775967280081e08dc7051504646174610177027b7d280081e08dc705150b65787465726e616c5f696401770679447467776e280081e08dc705150d65787465726e616c5f74797065017704746578742700c0dba80303065a2d49775967008881e08dc7051301770671576575506d2700c0dba80304066f6144664258022700c0dba803010636794c484a7601280081e08dc7052002696401770636794c484a76280081e08dc70520027479017709706172616772617068280081e08dc7052006706172656e7401772439356230346266642d346166332d353937632d613332362d393662333533383366303331280081e08dc70520086368696c6472656e017706433655362d50280081e08dc7052004646174610177027b7d280081e08dc705200b65787465726e616c5f69640177066f6144664258280081e08dc705200d65787465726e616c5f74797065017704746578742700c0dba8030306433655362d50008881e08dc7051e01770636794c484a768181e08dc70508022100c0dba8030406476250793763012100c0dba8030106594c4f6b46730100072100c0dba8030306576d5f4e5f6101c1c0dba8031881e08dc70513012100c0dba803040679443942546c012100c0dba8030106774d325637470100072100c0dba803030676396e4e757701c181e08dc7053681e08dc70513012700c0dba80304063762534b2d54022700c0dba803010638787843447801280081e08dc70543026964017706387878434478280081e08dc70543027479017709706172616772617068280081e08dc7054306706172656e7401772439356230346266642d346166332d353937632d613332362d393662333533383366303331280081e08dc70543086368696c6472656e0177064e326242576b280081e08dc7054304646174610177027b7d280081e08dc705430b65787465726e616c5f69640177063762534b2d54280081e08dc705430d65787465726e616c5f74797065017704746578742700c0dba80303064e326242576b00c881e08dc7054181e08dc70513017706387878434478040081e08dc7054207676f6f646279651ac0dba803002701046461746108646f63756d656e74012700c0dba8030006626c6f636b73012700c0dba80300046d657461012700c0dba803020c6368696c6472656e5f6d6170012700c0dba8030208746578745f6d6170012800c0dba8030007706167655f696401772439356230346266642d346166332d353937632d613332362d3936623335333833663033312700c0dba803012439356230346266642d346166332d353937632d613332362d393662333533383366303331012800c0dba8030602696401772439356230346266642d346166332d353937632d613332362d3936623335333833663033312800c0dba80306027479017704706167652800c0dba8030606706172656e740177002800c0dba80306086368696c6472656e01772439356230346266642d346166332d353937632d613332362d3936623335333833663033312800c0dba8030604646174610177027b7d2800c0dba803060b65787465726e616c5f6964017e2800c0dba803060d65787465726e616c5f74797065017e2700c0dba803032439356230346266642d346166332d353937632d613332362d393662333533383366303331002700c0dba803010a69323858633754506e57012800c0dba8030f02696401770a69323858633754506e572800c0dba8030f0274790177097061726167726170682800c0dba8030f06706172656e7401772439356230346266642d346166332d353937632d613332362d3936623335333833663033312800c0dba8030f086368696c6472656e01770a626958746c545a5043382800c0dba8030f04646174610177027b7d2800c0dba8030f0b65787465726e616c5f696401770a5f7a356771654b61734b2800c0dba8030f0d65787465726e616c5f74797065017704746578742700c0dba803030a626958746c545a504338000800c0dba8030e01770a69323858633754506e572700c0dba803040a5f7a356771654b61734b0202a9a7c6f50901000581e08dc7050201042a18\";\n\npub const GRID_2_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🌨️\"\n    },\n    \"name\": \"grid2\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"cfdd03b2-b539-4f1d-84c3-3c0424bbd345\",\n    \"created_at\": 1726550230,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1726568298\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1726549363,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726549363\n    },\n    {\n      \"icon\": null,\n      \"name\": \"space2\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1726549363366}\",\n      \"layout\": 0,\n      \"view_id\": \"dc8bc7fe-cd63-4741-b62c-14086fd824b7\",\n      \"created_at\": 1726550230,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726550230\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🌨️\"\n      },\n      \"name\": \"grid2\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"cfdd03b2-b539-4f1d-84c3-3c0424bbd345\",\n      \"created_at\": 1726550230,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726568298\n    }\n  ]\n}\n\"#;\npub const GRID_2_HEX :&str =\"7b2264617461626173655f636f6c6c6162223a5b332c352c3137302c3233312c3134392c3232312c31352c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3137302c3233312c3134392c3232312c31352c302c322c3130352c3130302c312c33392c302c3137302c3233312c3134392c3232312c31352c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3137302c3233312c3134392c3232312c31352c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3137302c3233312c3134392c3232312c31352c302c352c3130392c3130312c3131362c39372c3131352c312c38392c3137302c3230322c3134352c3235322c31312c302c3136312c3137302c3233312c3134392c3232312c31352c312c312c34302c302c3137302c3233312c3134392c3232312c31352c342c332c3130352c3130352c3130302c312c3131392c33362c39392c3130322c3130302c3130302c34382c35312c39382c35302c34352c39382c35332c35312c35372c34352c35322c3130322c34392c3130302c34352c35362c35322c39392c35312c34352c35312c39392c34382c35322c35302c35322c39382c39382c3130302c35312c35322c35332c33392c302c3137302c3233312c3134392c3232312c31352c322c362c3130352c3130352c35332c3131392c37362c38382c312c34302c302c3137302c3230322c3134352c3235322c31312c322c322c3130352c3130302c312c3131392c362c3130352c3130352c35332c3131392c37362c38382c34302c302c3137302c3230322c3134352c3235322c31312c322c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3137302c3230322c3134352c3235322c31312c322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c34302c302c3137302c3230322c3134352c3235322c31312c322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c34302c302c3137302c3230322c3134352c3235322c31312c322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3137302c3230322c3134352c3235322c31312c322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3137302c3230322c3134352c3235322c31312c322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3137302c3230322c3134352c3235322c31312c392c312c34382c312c34302c302c3137302c3230322c3134352c3235322c31312c31302c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3137302c3233312c3134392c3232312c31352c322c362c3131352c3131302c38362c37342c39382c35352c312c34302c302c3137302c3230322c3134352c3235322c31312c31322c322c3130352c3130302c312c3131392c362c3131352c3131302c38362c37342c39382c35352c34302c302c3137302c3230322c3134352c3235322c31312c31322c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3137302c3230322c3134352c3235322c31312c31322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c33332c302c3137302c3230322c3134352c3235322c31312c31322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3137302c3230322c3134352c3235322c31312c31322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3137302c3230322c3134352c3235322c31312c31322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3137302c3230322c3134352c3235322c31312c31322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3137302c3230322c3134352c3235322c31312c31392c312c35312c312c33332c302c3137302c3230322c3134352c3235322c31312c32302c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3137302c3233312c3134392c3232312c31352c322c362c3130322c36352c35372c35362c37312c3131322c312c34302c302c3137302c3230322c3134352c3235322c31312c32322c322c3130352c3130302c312c3131392c362c3130322c36352c35372c35362c37312c3131322c34302c302c3137302c3230322c3134352c3235322c31312c32322c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3137302c3230322c3134352c3235322c31312c32322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c34302c302c3137302c3230322c3134352c3235322c31312c32322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c34302c302c3137302c3230322c3134352c3235322c31312c32322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3137302c3230322c3134352c3235322c31312c32322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3137302c3230322c3134352c3235322c31312c32322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3137302c3230322c3134352c3235322c31312c32392c312c35332c312c33392c302c3137302c3233312c3134392c3232312c31352c332c33362c39392c3130322c3130302c3130302c34382c35312c39382c35302c34352c39382c35332c35312c35372c34352c35322c3130322c34392c3130302c34352c35362c35322c39392c35312c34352c35312c39392c34382c35322c35302c35322c39382c39382c3130302c35312c35322c35332c312c34302c302c3137302c3230322c3134352c3235322c31312c33312c322c3130352c3130302c312c3131392c33362c39392c3130322c3130302c3130302c34382c35312c39382c35302c34352c39382c35332c35312c35372c34352c35322c3130322c34392c3130302c34352c35362c35322c39392c35312c34352c35312c39392c34382c35322c35302c35322c39382c39382c3130302c35312c35322c35332c34302c302c3137302c3230322c3134352c3235322c31312c33312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c34302c302c3137302c3230322c3134352c3235322c31312c33312c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3137302c3230322c3134352c3235322c31312c33312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c33332c302c3137302c3230322c3134352c3235322c31312c33312c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c33392c302c3137302c3230322c3134352c3235322c31312c33312c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3137302c3230322c3134352c3235322c31312c33312c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3137302c3230322c3134352c3235322c31312c33312c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3137302c3230322c3134352c3235322c31312c33392c362c3130322c36352c35372c35362c37312c3131322c312c34302c302c3137302c3230322c3134352c3235322c31312c34302c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3137302c3230322c3134352c3235322c31312c34302c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3137302c3230322c3134352c3235322c31312c34302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3137302c3230322c3134352c3235322c31312c33392c362c3130352c3130352c35332c3131392c37362c38382c312c34302c302c3137302c3230322c3134352c3235322c31312c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3137302c3230322c3134352c3235322c31312c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3137302c3230322c3134352c3235322c31312c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3137302c3230322c3134352c3235322c31312c33392c362c3131352c3131302c38362c37342c39382c35352c312c34302c302c3137302c3230322c3134352c3235322c31312c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3137302c3230322c3134352c3235322c31312c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3137302c3230322c3134352c3235322c31312c34382c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3137302c3230322c3134352c3235322c31312c33312c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3137302c3230322c3134352c3235322c31312c33312c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3137302c3230322c3134352c3235322c31312c33312c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3137302c3230322c3134352c3235322c31312c33312c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3137302c3230322c3134352c3235322c31312c35352c332c3131382c312c322c3130352c3130302c3131392c362c3130352c3130352c35332c3131392c37362c38382c3131382c312c322c3130352c3130302c3131392c362c3131352c3131302c38362c37342c39382c35352c3131382c312c322c3130352c3130302c3131392c362c3130322c36352c35372c35362c37312c3131322c33392c302c3137302c3230322c3134352c3235322c31312c33312c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3137302c3230322c3134352c3235322c31312c35392c332c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c34392c3130322c35372c34382c35302c35362c35332c39382c34352c35302c39392c39382c35322c34352c35322c35322c35372c3130312c34352c39382c35332c35342c39382c34352c35332c39372c39372c35312c3130322c35362c39372c35342c39382c35302c35312c39382c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c39372c35362c39382c35342c39382c39372c35352c35312c34352c39392c35342c35332c35372c34352c35322c3130322c35352c35352c34352c35372c34392c34392c39372c34352c34392c3130312c35332c39392c3130302c35302c35362c35352c35322c35362c3130302c35352c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35342c3130302c35372c3130312c34392c35302c34382c3130302c34352c35372c39372c35362c35352c34352c35322c3130302c39392c39392c34352c39372c39382c35362c35352c34352c35362c35352c34382c3130322c35372c35332c3130302c3130312c35362c39392c35372c39392c3136312c3137302c3230322c3134352c3235322c31312c31362c312c3136312c3137302c3230322c3134352c3235322c31312c32312c312c3136312c3137302c3230322c3134352c3235322c31312c36332c312c3136312c3137302c3230322c3134352c3235322c31312c36342c312c3136382c3137302c3230322c3134352c3235322c31312c36352c312c3132322c302c302c302c302c3130322c3233332c31362c3233302c3136382c3137302c3230322c3134352c3235322c31312c36362c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c35352c34352c35342c37322c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c34372c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3131342c3132302c3131312c35322c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3131382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c38352c3130302c38332c38382c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3132322c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c3136312c3137302c3230322c3134352c3235322c31312c33362c312c3133362c3137302c3230322c3134352c3235322c31312c35382c312c3131382c312c322c3130352c3130302c3131392c362c39352c35322c38392c3131372c3131362c3132322c33392c302c3137302c3230322c3134352c3235322c31312c33392c362c39352c35322c38392c3131372c3131362c3132322c312c34302c302c3137302c3230322c3134352c3235322c31312c37312c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3137302c3233312c3134392c3232312c31352c322c362c39352c35322c38392c3131372c3131362c3132322c312c34302c302c3137302c3230322c3134352c3235322c31312c37332c322c3130352c3130302c312c3131392c362c39352c35322c38392c3131372c3131362c3132322c33332c302c3137302c3230322c3134352c3235322c31312c37332c342c3131302c39372c3130392c3130312c312c34302c302c3137302c3230322c3134352c3235322c31312c37332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c32352c33332c302c3137302c3230322c3134352c3235322c31312c37332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3137302c3230322c3134352c3235322c31312c37332c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c33332c302c3137302c3230322c3134352c3235322c31312c37332c322c3131362c3132312c312c33392c302c3137302c3230322c3134352c3235322c31312c37332c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3137302c3230322c3134352c3235322c31312c38302c312c34382c312c34302c302c3137302c3230322c3134352c3235322c31312c38312c342c3130302c39372c3131362c39372c312c3131392c302c3136312c3137302c3230322c3134352c3235322c31312c37372c312c3136382c3137302c3230322c3134352c3235322c31312c37392c312c3132322c302c302c302c302c302c302c302c31302c3136382c3137302c3230322c3134352c3235322c31312c37352c312c3131392c382c38322c3130312c3130382c39372c3131362c3130352c3131312c3131302c33392c302c3137302c3230322c3134352c3235322c31312c38302c322c34392c34382c312c33332c302c3137302c3230322c3134352c3235322c31312c38362c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3136312c3137302c3230322c3134352c3235322c31312c38332c312c34302c302c3137302c3230322c3134352c3235322c31312c38312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c302c3136382c3137302c3230322c3134352c3235322c31312c36392c312c3132322c302c302c302c302c3130322c3233332c31372c32382c3136382c3137302c3230322c3134352c3235322c31312c38382c312c3132322c302c302c302c302c3130322c3233332c31372c33382c3136382c3137302c3230322c3134352c3235322c31312c38372c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c312c3136372c3136362c3133302c3231302c362c302c3136382c3137302c3230322c3134352c3235322c31312c302c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c322c3137302c3233312c3134392c3232312c31352c312c312c312c3137302c3230322c3134352c3235322c31312c31312c302c312c31362c312c32312c312c33362c312c36332c342c36392c312c37352c312c37372c312c37392c312c38332c312c38372c325d2c2264617461626173655f726f775f636f6c6c616273223a7b2236643965313230642d396138372d346463632d616238372d383730663935646538633963223a5b322c31302c3234362c3232372c3234312c3137302c382c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3234362c3232372c3234312c3137302c382c302c322c3130352c3130302c312c3131392c33362c35342c3130302c35372c3130312c34392c35302c34382c3130302c34352c35372c39372c35362c35352c34352c35322c3130302c39392c39392c34352c39372c39382c35362c35352c34352c35362c35352c34382c3130322c35372c35332c3130302c3130312c35362c39392c35372c39392c34302c302c3234362c3232372c3234312c3137302c382c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c34302c302c3234362c3232372c3234312c3137302c382c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3234362c3232372c3234312c3137302c382c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3234362c3232372c3234312c3137302c382c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c33332c302c3234362c3232372c3234312c3137302c382c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3234362c3232372c3234312c3137302c382c302c352c39392c3130312c3130382c3130382c3131352c312c32352c3139392c3234392c3137362c36332c302c3136312c3234362c3232372c3234312c3137302c382c382c312c33392c302c3234362c3232372c3234312c3137302c382c392c362c3130352c3130352c35332c3131392c37362c38382c312c34302c302c3139392c3234392c3137362c36332c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3232352c34302c302c3139392c3234392c3137362c36332c312c342c3130302c39372c3131362c39372c312c3131392c312c34372c34302c302c3139392c3234392c3137362c36332c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139392c3234392c3137362c36332c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3232352c3136312c3139392c3234392c3137362c36332c302c312c33392c302c3234362c3232372c3234312c3137302c382c392c362c3131352c3131302c38362c37342c39382c35352c312c34302c302c3139392c3234392c3137362c36332c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3233302c34302c302c3139392c3234392c3137362c36332c372c342c3130302c39372c3131362c39372c312c3131392c342c35352c34352c35342c37322c34302c302c3139392c3234392c3137362c36332c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3139392c3234392c3137362c36332c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3233302c3136312c3139392c3234392c3137362c36332c362c312c33392c302c3234362c3232372c3234312c3137302c382c392c362c3130322c36352c35372c35362c37312c3131322c312c34302c302c3139392c3234392c3137362c36332c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c34302c302c3139392c3234392c3137362c36332c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3139392c3234392c3137362c36332c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3139392c3234392c3137362c36332c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c3136382c3139392c3234392c3137362c36332c31322c312c3132322c302c302c302c302c3130322c3233332c31372c34332c33392c302c3234362c3232372c3234312c3137302c382c392c362c39352c35322c38392c3131372c3131362c3132322c312c34302c302c3139392c3234392c3137362c36332c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c34332c33392c302c3139392c3234392c3137362c36332c31392c342c3130302c39372c3131362c39372c302c382c302c3139392c3234392c3137362c36332c32312c312c3131392c33362c35372c3130302c35342c35352c3130302c34392c35322c3130302c34352c35312c35312c35372c35372c34352c35322c35302c35372c34382c34352c39372c39372c3130322c39392c34352c39382c34392c35362c35332c35352c35322c35302c35372c35332c35322c35352c35342c34302c302c3139392c3234392c3137362c36332c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c34302c302c3139392c3234392c3137362c36332c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c34332c322c3234362c3232372c3234312c3137302c382c312c382c312c3139392c3234392c3137362c36332c332c302c312c362c312c31322c315d2c2261386236626137332d633635392d346637372d393131612d316535636432383734386437223a5b322c31302c3136332c3138382c3137322c3137362c392c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3136332c3138382c3137322c3137362c392c302c322c3130352c3130302c312c3131392c33362c39372c35362c39382c35342c39382c39372c35352c35312c34352c39392c35342c35332c35372c34352c35322c3130322c35352c35352c34352c35372c34392c34392c39372c34352c34392c3130312c35332c39392c3130302c35302c35362c35352c35322c35362c3130302c35352c34302c302c3136332c3138382c3137322c3137362c392c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c34302c302c3136332c3138382c3137322c3137362c392c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3136332c3138382c3137322c3137362c392c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3136332c3138382c3137322c3137362c392c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c33332c302c3136332c3138382c3137322c3137362c392c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3136332c3138382c3137322c3137362c392c302c352c39392c3130312c3130382c3130382c3131352c312c32352c3134362c3230302c3231342c3135322c382c302c3136312c3136332c3138382c3137322c3137362c392c382c312c33392c302c3136332c3138382c3137322c3137362c392c392c362c3130352c3130352c35332c3131392c37362c38382c312c34302c302c3134362c3230302c3231342c3135322c382c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3232342c34302c302c3134362c3230302c3231342c3135322c382c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3134362c3230302c3231342c3135322c382c312c342c3130302c39372c3131362c39372c312c3131392c312c3131382c34302c302c3134362c3230302c3231342c3135322c382c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3232342c3136312c3134362c3230302c3231342c3135322c382c302c312c33392c302c3136332c3138382c3137322c3137362c392c392c362c3131352c3131302c38362c37342c39382c35352c312c34302c302c3134362c3230302c3231342c3135322c382c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3232382c34302c302c3134362c3230302c3231342c3135322c382c372c342c3130302c39372c3131362c39372c312c3131392c342c3131342c3132302c3131312c35322c34302c302c3134362c3230302c3231342c3135322c382c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3134362c3230302c3231342c3135322c382c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3232382c3136312c3134362c3230302c3231342c3135322c382c362c312c33392c302c3136332c3138382c3137322c3137362c392c392c362c3130322c36352c35372c35362c37312c3131322c312c34302c302c3134362c3230302c3231342c3135322c382c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c34302c302c3134362c3230302c3231342c3135322c382c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3134362c3230302c3231342c3135322c382c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3134362c3230302c3231342c3135322c382c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c3136382c3134362c3230302c3231342c3135322c382c31322c312c3132322c302c302c302c302c3130322c3233332c31372c34322c33392c302c3136332c3138382c3137322c3137362c392c392c362c39352c35322c38392c3131372c3131362c3132322c312c34302c302c3134362c3230302c3231342c3135322c382c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c34322c34302c302c3134362c3230302c3231342c3135322c382c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33392c302c3134362c3230302c3231342c3135322c382c31392c342c3130302c39372c3131362c39372c302c382c302c3134362c3230302c3231342c3135322c382c32322c312c3131392c33362c3130312c35302c34392c3130322c39382c3130312c3130302c35342c34352c3130302c39392c3130302c3130322c34352c35322c3130322c34382c35362c34352c39382c35312c39392c34382c34352c35352c39382c34392c35332c34382c34392c39382c35322c35362c34382c39392c35312c34302c302c3134362c3230302c3231342c3135322c382c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c34322c322c3134362c3230302c3231342c3135322c382c332c302c312c362c312c31322c312c3136332c3138382c3137322c3137362c392c312c382c315d2c2231663930323835622d326362342d343439652d623536622d356161336638613662323362223a5b322c32352c3231322c3132392c3137392c3137382c31352c302c3136312c3137362c3233362c3135322c34342c382c312c33392c302c3137362c3233362c3135322c34342c392c362c3130352c3130352c35332c3131392c37362c38382c312c34302c302c3231322c3132392c3137392c3137382c31352c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3232332c34302c302c3231322c3132392c3137392c3137382c31352c312c342c3130302c39372c3131362c39372c312c3131392c312c3132322c34302c302c3231322c3132392c3137392c3137382c31352c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3231322c3132392c3137392c3137382c31352c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3232332c3136312c3231322c3132392c3137392c3137382c31352c302c312c33392c302c3137362c3233362c3135322c34342c392c362c3131352c3131302c38362c37342c39382c35352c312c34302c302c3231322c3132392c3137392c3137382c31352c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3232372c34302c302c3231322c3132392c3137392c3137382c31352c372c342c3130302c39372c3131362c39372c312c3131392c342c38352c3130302c38332c38382c34302c302c3231322c3132392c3137392c3137382c31352c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3231322c3132392c3137392c3137382c31352c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3232372c3136312c3231322c3132392c3137392c3137382c31352c362c312c33392c302c3137362c3233362c3135322c34342c392c362c3130322c36352c35372c35362c37312c3131322c312c34302c302c3231322c3132392c3137392c3137382c31352c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c34302c302c3231322c3132392c3137392c3137382c31352c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3231322c3132392c3137392c3137382c31352c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3231322c3132392c3137392c3137382c31352c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3233312c3136382c3231322c3132392c3137392c3137382c31352c31322c312c3132322c302c302c302c302c3130322c3233332c31372c34302c33392c302c3137362c3233362c3135322c34342c392c362c39352c35322c38392c3131372c3131362c3132322c312c34302c302c3231322c3132392c3137392c3137382c31352c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c34302c33392c302c3231322c3132392c3137392c3137382c31352c31392c342c3130302c39372c3131362c39372c302c382c302c3231322c3132392c3137392c3137382c31352c32312c312c3131392c33362c39372c39382c35312c34382c35352c39382c35322c3130312c34352c35302c39392c35342c3130312c34352c35322c35362c35372c39392c34352c35362c39382c35312c3130322c34352c35322c35322c35372c34382c35372c3130322c35362c34392c35332c34392c35372c35312c34302c302c3231322c3132392c3137392c3137382c31352c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c34302c302c3231322c3132392c3137392c3137382c31352c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c34302c31302c3137362c3233362c3135322c34342c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3137362c3233362c3135322c34342c302c322c3130352c3130302c312c3131392c33362c34392c3130322c35372c34382c35302c35362c35332c39382c34352c35302c39392c39382c35322c34352c35322c35322c35372c3130312c34352c39382c35332c35342c39382c34352c35332c39372c39372c35312c3130322c35362c39372c35342c39382c35302c35312c39382c34302c302c3137362c3233362c3135322c34342c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c34302c302c3137362c3233362c3135322c34342c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3137362c3233362c3135322c34342c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3137362c3233362c3135322c34342c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3231342c33332c302c3137362c3233362c3135322c34342c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3137362c3233362c3135322c34342c302c352c39392c3130312c3130382c3130382c3131352c312c322c3137362c3233362c3135322c34342c312c382c312c3231322c3132392c3137392c3137382c31352c332c302c312c362c312c31322c315d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2263666464303362322d623533392d346631642d383463332d336330343234626264333435225d2c2264617461626173655f72656c6174696f6e73223a7b2261333765356432332d353238352d346235622d393732662d316363626632346333353838223a2261643534393335302d363563382d343064302d613362322d663035633866616531386262222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2232633338313638382d623330372d343039622d623231382d323065363963303866313036223a2231386437323538392d383064372d343034312d393334322d356435373266616362376339222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2265633863326236352d343764312d346636632d623430382d313436303461663964653535223a2263666464303362322d623533392d346631642d383463332d336330343234626264333435222c2235633863626566302d313063332d346238632d383231392d653138323364313039636537223a2238613366633632392d616431332d343265662d613264322d663231636562346331626537222c2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264222c2237323139376235322d666530642d343962382d623438662d616136323732633032393666223a2264666630393733632d393034382d346532332d393639372d303463623134633531386231222c2239643733383436362d313835642d343765302d616631632d393138356361323066323733223a2235656366366161312d643464362d343765342d616630662d336137613966643832393964222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2232613437376165652d613731372d346364302d623538392d373561333337353162393333223a2265376533613336312d326666612d346264642d383666382d653438663831316165336232227d7d\";\n\npub const GRID_3_META: &str = r#\"\n{\n  \"view\": {\n    \"icon\": {\n      \"ty\": 0,\n      \"value\": \"🧈\"\n    },\n    \"name\": \"grid3\",\n    \"extra\": null,\n    \"layout\": 1,\n    \"view_id\": \"5ecf6aa1-d4d6-47e4-af0f-3a7a9fd8299d\",\n    \"created_at\": 1726549942,\n    \"created_by\": 311828434584080384,\n    \"child_views\": null,\n    \"last_edited_by\": 311828434584080384,\n    \"last_edited_time\": 1726551668\n  },\n  \"child_views\": [],\n  \"ancestor_views\": [\n    {\n      \"icon\": null,\n      \"name\": \"Workspace\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"d69262ac-87e1-41e0-af26-3841c598c0c6\",\n      \"created_at\": 1726549363,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726549363\n    },\n    {\n      \"icon\": null,\n      \"name\": \"space2\",\n      \"extra\": \"{\\\"is_space\\\":true,\\\"space_icon\\\":\\\"interface_essential/home-3\\\",\\\"space_icon_color\\\":\\\"0xFFA34AFD\\\",\\\"space_permission\\\":0,\\\"space_created_at\\\":1726549363366}\",\n      \"layout\": 0,\n      \"view_id\": \"dc8bc7fe-cd63-4741-b62c-14086fd824b7\",\n      \"created_at\": 1726550230,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726550230\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🫗\"\n      },\n      \"name\": \"doc4\",\n      \"extra\": null,\n      \"layout\": 0,\n      \"view_id\": \"bb429fb8-3bb8-4f8c-99d8-0d8c48047efc\",\n      \"created_at\": 1726549942,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726551153\n    },\n    {\n      \"icon\": {\n        \"ty\": 0,\n        \"value\": \"🧈\"\n      },\n      \"name\": \"grid3\",\n      \"extra\": null,\n      \"layout\": 1,\n      \"view_id\": \"5ecf6aa1-d4d6-47e4-af0f-3a7a9fd8299d\",\n      \"created_at\": 1726549942,\n      \"created_by\": 311828434584080384,\n      \"child_views\": null,\n      \"last_edited_by\": 311828434584080384,\n      \"last_edited_time\": 1726551668\n    }\n  ]\n}\n\"#;\n\npub const GRID_3_HEX: &str = \"7b2264617461626173655f636f6c6c6162223a5b342c39312c3139382c3231382c3135372c3133342c31322c302c3136312c3231302c3231302c3136332c3232312c392c312c312c34302c302c3231302c3231302c3136332c3232312c392c342c332c3130352c3130352c3130302c312c3131392c33362c35332c3130312c39392c3130322c35342c39372c39372c34392c34352c3130302c35322c3130302c35342c34352c35322c35352c3130312c35322c34352c39372c3130322c34382c3130322c34352c35312c39372c35352c39372c35372c3130322c3130302c35362c35302c35372c35372c3130302c33392c302c3231302c3231302c3136332c3232312c392c322c362c39352c36392c3130322c37322c37332c3131312c312c34302c302c3139382c3231382c3135372c3133342c31322c322c322c3130352c3130302c312c3131392c362c39352c36392c3130322c37322c37332c3131312c34302c302c3139382c3231382c3135372c3133342c31322c322c342c3131302c39372c3130392c3130312c312c3131392c342c37382c39372c3130392c3130312c34302c302c3139382c3231382c3135372c3133342c31322c322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c34302c302c3139382c3231382c3135372c3133342c31322c322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c34302c302c3139382c3231382c3135372c3133342c31322c322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132302c34302c302c3139382c3231382c3135372c3133342c31322c322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3139382c3231382c3135372c3133342c31322c322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3139382c3231382c3135372c3133342c31322c392c312c34382c312c34302c302c3139382c3231382c3135372c3133342c31322c31302c342c3130302c39372c3131362c39372c312c3131392c302c33392c302c3231302c3231302c3136332c3232312c392c322c362c3131332c39352c39352c38362c3131382c3131352c312c34302c302c3139382c3231382c3135372c3133342c31322c31322c322c3130352c3130302c312c3131392c362c3131332c39352c39352c38362c3131382c3131352c34302c302c3139382c3231382c3135372c3133342c31322c31322c342c3131302c39372c3130392c3130312c312c3131392c342c38342c3132312c3131322c3130312c34302c302c3139382c3231382c3135372c3133342c31322c31322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c33332c302c3139382c3231382c3135372c3133342c31322c31322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3139382c3231382c3135372c3133342c31322c31322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3139382c3231382c3135372c3133342c31322c31322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c332c33392c302c3139382c3231382c3135372c3133342c31322c31322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3139382c3231382c3135372c3133342c31322c31392c312c35312c312c33332c302c3139382c3231382c3135372c3133342c31322c32302c372c39392c3131312c3131302c3131362c3130312c3131302c3131362c312c33392c302c3231302c3231302c3136332c3232312c392c322c362c37382c36352c3130352c35362c3130352c38382c312c34302c302c3139382c3231382c3135372c3133342c31322c32322c322c3130352c3130302c312c3131392c362c37382c36352c3130352c35362c3130352c38382c34302c302c3139382c3231382c3135372c3133342c31322c32322c342c3131302c39372c3130392c3130312c312c3131392c342c36382c3131312c3131302c3130312c34302c302c3139382c3231382c3135372c3133342c31322c32322c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c34302c302c3139382c3231382c3135372c3133342c31322c32322c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c34302c302c3139382c3231382c3135372c3133342c31322c32322c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c34302c302c3139382c3231382c3135372c3133342c31322c32322c322c3131362c3132312c312c3132322c302c302c302c302c302c302c302c352c33392c302c3139382c3231382c3135372c3133342c31322c32322c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3139382c3231382c3135372c3133342c31322c32392c312c35332c312c33392c302c3231302c3231302c3136332c3232312c392c332c33362c35332c3130312c39392c3130322c35342c39372c39372c34392c34352c3130302c35322c3130302c35342c34352c35322c35352c3130312c35322c34352c39372c3130322c34382c3130322c34352c35312c39372c35352c39372c35372c3130322c3130302c35362c35302c35372c35372c3130302c312c34302c302c3139382c3231382c3135372c3133342c31322c33312c322c3130352c3130302c312c3131392c33362c35332c3130312c39392c3130322c35342c39372c39372c34392c34352c3130302c35322c3130302c35342c34352c35322c35352c3130312c35322c34352c39372c3130322c34382c3130322c34352c35312c39372c35352c39372c35372c3130322c3130302c35362c35302c35372c35372c3130302c34302c302c3139382c3231382c3135372c3133342c31322c33312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c34302c302c3139382c3231382c3135372c3133342c31322c33312c342c3131302c39372c3130392c3130312c312c3131392c382c38352c3131302c3131362c3130352c3131362c3130382c3130312c3130302c34302c302c3139382c3231382c3135372c3133342c31322c33312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c33332c302c3139382c3231382c3135372c3133342c31322c33312c31312c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c39352c39372c3131362c312c33392c302c3139382c3231382c3135372c3133342c31322c33312c31352c3130382c39372c3132312c3131312c3131372c3131362c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c34302c302c3139382c3231382c3135372c3133342c31322c33312c362c3130382c39372c3132312c3131312c3131372c3131362c312c3132322c302c302c302c302c302c302c302c302c33392c302c3139382c3231382c3135372c3133342c31322c33312c31342c3130322c3130352c3130312c3130382c3130302c39352c3131352c3130312c3131362c3131362c3130352c3131302c3130332c3131352c312c33392c302c3139382c3231382c3135372c3133342c31322c33392c362c37382c36352c3130352c35362c3130352c38382c312c34302c302c3139382c3231382c3135372c3133342c31322c34302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139382c3231382c3135372c3133342c31322c34302c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3139382c3231382c3135372c3133342c31322c34302c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3139382c3231382c3135372c3133342c31322c33392c362c39352c36392c3130322c37322c37332c3131312c312c34302c302c3139382c3231382c3135372c3133342c31322c34342c342c3131392c3131342c39372c3131322c312c3132302c34302c302c3139382c3231382c3135372c3133342c31322c34342c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139382c3231382c3135372c3133342c31322c34342c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c33392c302c3139382c3231382c3135372c3133342c31322c33392c362c3131332c39352c39352c38362c3131382c3131352c312c34302c302c3139382c3231382c3135372c3133342c31322c34382c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3139382c3231382c3135372c3133342c31322c34382c352c3131392c3130352c3130302c3131362c3130342c312c3132322c302c302c302c302c302c302c302c3135302c34302c302c3139382c3231382c3135372c3133342c31322c34382c342c3131392c3131342c39372c3131322c312c3132302c33392c302c3139382c3231382c3135372c3133342c31322c33312c372c3130322c3130352c3130382c3131362c3130312c3131342c3131352c302c33392c302c3139382c3231382c3135372c3133342c31322c33312c362c3130332c3131342c3131312c3131372c3131322c3131352c302c33392c302c3139382c3231382c3135372c3133342c31322c33312c352c3131352c3131312c3131342c3131362c3131352c302c33392c302c3139382c3231382c3135372c3133342c31322c33312c31322c3130322c3130352c3130312c3130382c3130302c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3139382c3231382c3135372c3133342c31322c35352c332c3131382c312c322c3130352c3130302c3131392c362c39352c36392c3130322c37322c37332c3131312c3131382c312c322c3130352c3130302c3131392c362c3131332c39352c39352c38362c3131382c3131352c3131382c312c322c3130352c3130302c3131392c362c37382c36352c3130352c35362c3130352c38382c33392c302c3139382c3231382c3135372c3133342c31322c33312c31302c3131342c3131312c3131392c39352c3131312c3131342c3130302c3130312c3131342c3131352c302c382c302c3139382c3231382c3135372c3133342c31322c35392c332c3131382c322c322c3130352c3130302c3131392c33362c39372c39382c35312c34382c35352c39382c35322c3130312c34352c35302c39392c35342c3130312c34352c35322c35362c35372c39392c34352c35362c39382c35312c3130322c34352c35322c35322c35372c34382c35372c3130322c35362c34392c35332c34392c35372c35312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c322c3130352c3130302c3131392c33362c3130312c35302c34392c3130322c39382c3130312c3130302c35342c34352c3130302c39392c3130302c3130322c34352c35322c3130322c34382c35362c34352c39382c35312c39392c34382c34352c35352c39382c34392c35332c34382c34392c39382c35322c35362c34382c39392c35312c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c3131382c322c362c3130342c3130312c3130352c3130332c3130342c3131362c3132352c36302c322c3130352c3130302c3131392c33362c35372c3130302c35342c35352c3130302c34392c35322c3130302c34352c35312c35312c35372c35372c34352c35322c35302c35372c34382c34352c39372c39372c3130322c39392c34352c39382c34392c35362c35332c35352c35322c35302c35372c35332c35322c35352c35342c3136312c3139382c3231382c3135372c3133342c31322c31362c312c3136312c3139382c3231382c3135372c3133342c31322c32312c312c3136312c3139382c3231382c3135372c3133342c31322c36332c312c3136312c3139382c3231382c3135372c3133342c31322c36342c312c3136382c3139382c3231382c3135372c3133342c31322c36352c312c3132322c302c302c302c302c3130322c3233332c31362c3134322c3136382c3139382c3231382c3135372c3133342c31322c36362c312c3131392c3136322c312c3132332c33342c3131312c3131322c3131362c3130352c3131312c3131302c3131352c33342c35382c39312c3132332c33342c3130352c3130302c33342c35382c33342c3131362c3130382c38302c3131392c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c39392c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c37362c3130352c3130332c3130342c3131362c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c36382c3130362c3131322c3131332c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130342c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3130352c3131302c3130372c33342c3132352c34342c3132332c33342c3130352c3130302c33342c35382c33342c3130372c39382c36392c3131342c33342c34342c33342c3131302c39372c3130392c3130312c33342c35382c33342c3130382c33342c34342c33342c39392c3131312c3130382c3131312c3131342c33342c35382c33342c38302c3131372c3131342c3131322c3130382c3130312c33342c3132352c39332c34342c33342c3130302c3130352c3131352c39372c39382c3130382c3130312c39352c39392c3131312c3130382c3131312c3131342c33342c35382c3130322c39372c3130382c3131352c3130312c3132352c3136312c3139382c3231382c3135372c3133342c31322c33362c312c3133362c3139382c3231382c3135372c3133342c31322c35382c312c3131382c312c322c3130352c3130302c3131392c362c3131302c36382c3130312c37302c36352c36362c33392c302c3139382c3231382c3135372c3133342c31322c33392c362c3131302c36382c3130312c37302c36352c36362c312c34302c302c3139382c3231382c3135372c3133342c31322c37312c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132322c302c302c302c302c302c302c302c302c33392c302c3231302c3231302c3136332c3232312c392c322c362c3131302c36382c3130312c37302c36352c36362c312c34302c302c3139382c3231382c3135372c3133342c31322c37332c322c3130352c3130302c312c3131392c362c3131302c36382c3130312c37302c36352c36362c33332c302c3139382c3231382c3135372c3133342c31322c37332c342c3131302c39372c3130392c3130312c312c34302c302c3139382c3231382c3135372c3133342c31322c37332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3233372c33332c302c3139382c3231382c3135372c3133342c31322c37332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c34302c302c3139382c3231382c3135372c3133342c31322c37332c31302c3130352c3131352c39352c3131322c3131342c3130352c3130392c39372c3131342c3132312c312c3132312c33332c302c3139382c3231382c3135372c3133342c31322c37332c322c3131362c3132312c312c33392c302c3139382c3231382c3135372c3133342c31322c37332c31312c3131362c3132312c3131322c3130312c39352c3131312c3131322c3131362c3130352c3131312c3131302c312c33392c302c3139382c3231382c3135372c3133342c31322c38302c312c34382c312c34302c302c3139382c3231382c3135372c3133342c31322c38312c342c3130302c39372c3131362c39372c312c3131392c302c3136312c3139382c3231382c3135372c3133342c31322c37372c312c3136382c3139382c3231382c3135372c3133342c31322c37392c312c3132322c302c302c302c302c302c302c302c31302c3136382c3139382c3231382c3135372c3133342c31322c37352c312c3131392c382c38322c3130312c3130382c39372c3131362c3130352c3131312c3131302c33392c302c3139382c3231382c3135372c3133342c31322c38302c322c34392c34382c312c33332c302c3139382c3231382c3135372c3133342c31322c38362c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3136312c3139382c3231382c3135372c3133342c31322c38332c312c34302c302c3139382c3231382c3135372c3133342c31322c38312c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c302c3136382c3139382c3231382c3135372c3133342c31322c36392c312c3132322c302c302c302c302c3130322c3233332c31362c3234352c3136312c3139382c3231382c3135372c3133342c31322c38382c312c3136312c3139382c3231382c3135372c3133342c31322c38372c312c3136382c3139382c3231382c3135372c3133342c31322c39312c312c3132322c302c302c302c302c3130322c3233332c31372c31362c3136382c3139382c3231382c3135372c3133342c31322c39322c312c3131392c33362c3130312c39392c35362c39392c35302c39382c35342c35332c34352c35322c35352c3130302c34392c34352c35322c3130322c35342c39392c34352c39382c35322c34382c35362c34352c34392c35322c35342c34382c35322c39372c3130322c35372c3130302c3130312c35332c35332c352c3231302c3231302c3136332c3232312c392c302c33392c312c342c3130302c39372c3131362c39372c382c3130302c39372c3131362c39372c39382c39372c3131352c3130312c312c33332c302c3231302c3231302c3136332c3232312c392c302c322c3130352c3130302c312c33392c302c3231302c3231302c3136332c3232312c392c302c362c3130322c3130352c3130312c3130382c3130302c3131352c312c33392c302c3231302c3231302c3136332c3232312c392c302c352c3131382c3130352c3130312c3131392c3131352c312c33392c302c3231302c3231302c3136332c3232312c392c302c352c3130392c3130312c3131362c39372c3131352c312c312c3136362c3139352c3136322c3138372c392c302c3136382c3233372c3133302c3133352c3139392c372c302c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c312c3233372c3133302c3133352c3139392c372c302c3136312c3139382c3231382c3135372c3133342c31322c302c312c332c3233372c3133302c3133352c3139392c372c312c302c312c3231302c3231302c3136332c3232312c392c312c312c312c3139382c3231382c3135372c3133342c31322c31322c302c312c31362c312c32312c312c33362c312c36332c342c36392c312c37352c312c37372c312c37392c312c38332c312c38372c322c39312c325d2c2264617461626173655f726f775f636f6c6c616273223a7b2265323166626564362d646364662d346630382d623363302d376231353031623438306333223a5b322c32352c3234362c3232392c3138322c3137352c31352c302c3136312c3139392c3231382c3136342c3235322c362c382c312c33392c302c3139392c3231382c3136342c3235322c362c392c362c39352c36392c3130322c37322c37332c3131312c312c34302c302c3234362c3232392c3138322c3137352c31352c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3133342c34302c302c3234362c3232392c3138322c3137352c31352c312c342c3130302c39372c3131362c39372c312c3131392c312c3130342c34302c302c3234362c3232392c3138322c3137352c31352c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3234362c3232392c3138322c3137352c31352c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3133342c3136312c3234362c3232392c3138322c3137352c31352c302c312c33392c302c3139392c3231382c3136342c3235322c362c392c362c3131332c39352c39352c38362c3131382c3131352c312c34302c302c3234362c3232392c3138322c3137352c31352c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3134312c34302c302c3234362c3232392c3138322c3137352c31352c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3234362c3232392c3138322c3137352c31352c372c342c3130302c39372c3131362c39372c312c3131392c342c36382c3130362c3131322c3131332c34302c302c3234362c3232392c3138322c3137352c31352c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3134312c3136312c3234362c3232392c3138322c3137352c31352c362c312c33392c302c3139392c3231382c3136342c3235322c362c392c362c37382c36352c3130352c35362c3130352c38382c312c34302c302c3234362c3232392c3138322c3137352c31352c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3135372c34302c302c3234362c3232392c3138322c3137352c31352c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3234362c3232392c3138322c3137352c31352c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3234362c3232392c3138322c3137352c31352c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3135372c3136382c3234362c3232392c3138322c3137352c31352c31322c312c3132322c302c302c302c302c3130322c3233332c31372c32302c33392c302c3139392c3231382c3136342c3235322c362c392c362c3131302c36382c3130312c37302c36352c36362c312c34302c302c3234362c3232392c3138322c3137352c31352c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c32302c34302c302c3234362c3232392c3138322c3137352c31352c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33392c302c3234362c3232392c3138322c3137352c31352c31392c342c3130302c39372c3131362c39372c302c382c302c3234362c3232392c3138322c3137352c31352c32322c312c3131392c33362c39372c35362c39382c35342c39382c39372c35352c35312c34352c39392c35342c35332c35372c34352c35322c3130322c35352c35352c34352c35372c34392c34392c39372c34352c34392c3130312c35332c39392c3130302c35302c35362c35352c35322c35362c3130302c35352c34302c302c3234362c3232392c3138322c3137352c31352c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c32302c31302c3139392c3231382c3136342c3235322c362c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3139392c3231382c3136342c3235322c362c302c322c3130352c3130302c312c3131392c33362c3130312c35302c34392c3130322c39382c3130312c3130302c35342c34352c3130302c39392c3130302c3130322c34352c35322c3130322c34382c35362c34352c39382c35312c39392c34382c34352c35352c39382c34392c35332c34382c34392c39382c35322c35362c34382c39392c35312c34302c302c3139392c3231382c3136342c3235322c362c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c34302c302c3139392c3231382c3136342c3235322c362c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3139392c3231382c3136342c3235322c362c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3139392c3231382c3136342c3235322c362c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c33332c302c3139392c3231382c3136342c3235322c362c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3139392c3231382c3136342c3235322c362c302c352c39392c3130312c3130382c3130382c3131352c312c322c3234362c3232392c3138322c3137352c31352c332c302c312c362c312c31322c312c3139392c3231382c3136342c3235322c362c312c382c315d2c2261623330376234652d326336652d343839632d386233662d343439303966383135313933223a5b322c31302c3133362c3234342c3234392c3137352c31302c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3133362c3234342c3234392c3137352c31302c302c322c3130352c3130302c312c3131392c33362c39372c39382c35312c34382c35352c39382c35322c3130312c34352c35302c39392c35342c3130312c34352c35322c35362c35372c39392c34352c35362c39382c35312c3130322c34352c35322c35322c35372c34382c35372c3130322c35362c34392c35332c34392c35372c35312c34302c302c3133362c3234342c3234392c3137352c31302c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c34302c302c3133362c3234342c3234392c3137352c31302c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3133362c3234342c3234392c3137352c31302c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3133362c3234342c3234392c3137352c31302c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c33332c302c3133362c3234342c3234392c3137352c31302c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3133362c3234342c3234392c3137352c31302c302c352c39392c3130312c3130382c3130382c3131352c312c33312c3134372c3234382c3232392c3138312c352c302c3136312c3133362c3234342c3234392c3137352c31302c382c312c33392c302c3133362c3234342c3234392c3137352c31302c392c362c39352c36392c3130322c37322c37332c3131312c312c34302c302c3134372c3234382c3232392c3138312c352c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3133342c34302c302c3134372c3234382c3232392c3138312c352c312c342c3130302c39372c3131362c39372c312c3131392c312c3130382c34302c302c3134372c3234382c3232392c3138312c352c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3134372c3234382c3232392c3138312c352c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3133342c3136312c3134372c3234382c3232392c3138312c352c302c312c33392c302c3133362c3234342c3234392c3137352c31302c392c362c3131332c39352c39352c38362c3131382c3131352c312c34302c302c3134372c3234382c3232392c3138312c352c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3133392c33332c302c3134372c3234382c3232392c3138312c352c372c342c3130302c39372c3131362c39372c312c34302c302c3134372c3234382c3232392c3138312c352c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c33332c302c3134372c3234382c3232392c3138312c352c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3136312c3134372c3234382c3232392c3138312c352c362c312c3136312c3134372c3234382c3232392c3138312c352c392c312c3136312c3134372c3234382c3232392c3138312c352c31312c312c3136312c3134372c3234382c3232392c3138312c352c31322c312c3136382c3134372c3234382c3232392c3138312c352c31332c312c3131392c342c3130372c39382c36392c3131342c3136382c3134372c3234382c3232392c3138312c352c31342c312c3132322c302c302c302c302c3130322c3233332c31362c3134372c3136312c3134372c3234382c3232392c3138312c352c31352c312c33392c302c3133362c3234342c3234392c3137352c31302c392c362c37382c36352c3130352c35362c3130352c38382c312c34302c302c3134372c3234382c3232392c3138312c352c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3135372c34302c302c3134372c3234382c3232392c3138312c352c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3134372c3234382c3232392c3138312c352c31392c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3134372c3234382c3232392c3138312c352c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3135372c3136382c3134372c3234382c3232392c3138312c352c31382c312c3132322c302c302c302c302c3130322c3233332c31372c31382c33392c302c3133362c3234342c3234392c3137352c31302c392c362c3131302c36382c3130312c37302c36352c36362c312c34302c302c3134372c3234382c3232392c3138312c352c32352c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c31382c33392c302c3134372c3234382c3232392c3138312c352c32352c342c3130302c39372c3131362c39372c302c382c302c3134372c3234382c3232392c3138312c352c32372c312c3131392c33362c34392c3130322c35372c34382c35302c35362c35332c39382c34352c35302c39392c39382c35322c34352c35322c35322c35372c3130312c34352c39382c35332c35342c39382c34352c35332c39372c39372c35312c3130322c35362c39372c35342c39382c35302c35312c39382c34302c302c3134372c3234382c3232392c3138312c352c32352c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c34302c302c3134372c3234382c3232392c3138312c352c32352c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c31382c322c3133362c3234342c3234392c3137352c31302c312c382c312c3134372c3234382c3232392c3138312c352c352c302c312c362c312c392c312c31312c352c31382c315d2c2239643637643134642d333339392d343239302d616166632d623138353734323935343736223a5b322c32352c3230302c3134342c3138392c3136312c392c302c3136312c3138392c3232342c3134362c3136352c362c382c312c33392c302c3138392c3232342c3134362c3136352c362c392c362c39352c36392c3130322c37322c37332c3131312c312c34302c302c3230302c3134342c3138392c3136312c392c312c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3133352c34302c302c3230302c3134342c3138392c3136312c392c312c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c302c34302c302c3230302c3134342c3138392c3136312c392c312c342c3130302c39372c3131362c39372c312c3131392c312c39392c34302c302c3230302c3134342c3138392c3136312c392c312c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3133352c3136312c3230302c3134342c3138392c3136312c392c302c312c33392c302c3138392c3232342c3134362c3136352c362c392c362c3131332c39352c39352c38362c3131382c3131352c312c34302c302c3230302c3134342c3138392c3136312c392c372c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3134322c34302c302c3230302c3134342c3138392c3136312c392c372c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c332c34302c302c3230302c3134342c3138392c3136312c392c372c342c3130302c39372c3131362c39372c312c3131392c342c3131362c3130382c38302c3131392c34302c302c3230302c3134342c3138392c3136312c392c372c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3134322c3136312c3230302c3134342c3138392c3136312c392c362c312c33392c302c3138392c3232342c3134362c3136352c362c392c362c37382c36352c3130352c35362c3130352c38382c312c34302c302c3230302c3134342c3138392c3136312c392c31332c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31362c3135382c34302c302c3230302c3134342c3138392c3136312c392c31332c342c3130302c39372c3131362c39372c312c3131392c332c38392c3130312c3131352c34302c302c3230302c3134342c3138392c3136312c392c31332c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c352c34302c302c3230302c3134342c3138392c3136312c392c31332c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31362c3135382c3136382c3230302c3134342c3138392c3136312c392c31322c312c3132322c302c302c302c302c3130322c3233332c31372c32312c33392c302c3138392c3232342c3134362c3136352c362c392c362c3131302c36382c3130312c37302c36352c36362c312c34302c302c3230302c3134342c3138392c3136312c392c31392c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31372c32312c34302c302c3230302c3134342c3138392c3136312c392c31392c31302c3130322c3130352c3130312c3130382c3130302c39352c3131362c3132312c3131322c3130312c312c3132322c302c302c302c302c302c302c302c31302c33392c302c3230302c3134342c3138392c3136312c392c31392c342c3130302c39372c3131362c39372c302c382c302c3230302c3134342c3138392c3136312c392c32322c312c3131392c33362c35342c3130302c35372c3130312c34392c35302c34382c3130302c34352c35372c39372c35362c35352c34352c35322c3130302c39392c39392c34352c39372c39382c35362c35352c34352c35362c35352c34382c3130322c35372c35332c3130302c3130312c35362c39392c35372c39392c34302c302c3230302c3134342c3138392c3136312c392c31392c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c3132322c302c302c302c302c3130322c3233332c31372c32312c31302c3138392c3232342c3134362c3136352c362c302c33392c312c342c3130302c39372c3131362c39372c342c3130302c39372c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c342c3130392c3130312c3131362c39372c312c33392c312c342c3130302c39372c3131362c39372c372c39392c3131312c3130392c3130392c3130312c3131302c3131362c302c34302c302c3138392c3232342c3134362c3136352c362c302c322c3130352c3130302c312c3131392c33362c35372c3130302c35342c35352c3130302c34392c35322c3130302c34352c35312c35312c35372c35372c34352c35322c35302c35372c34382c34352c39372c39372c3130322c39392c34352c39382c34392c35362c35332c35352c35322c35302c35372c35332c35322c35352c35342c34302c302c3138392c3232342c3134362c3136352c362c302c31312c3130302c39372c3131362c39372c39382c39372c3131352c3130312c39352c3130352c3130302c312c3131392c33362c35372c3130302c35352c35312c35362c35322c35342c35342c34352c34392c35362c35332c3130302c34352c35322c35352c3130312c34382c34352c39372c3130322c34392c39392c34352c35372c34392c35362c35332c39392c39372c35302c34382c3130322c35302c35352c35312c34302c302c3138392c3232342c3134362c3136352c362c302c362c3130342c3130312c3130352c3130332c3130342c3131362c312c3132322c302c302c302c302c302c302c302c36302c34302c302c3138392c3232342c3134362c3136352c362c302c31302c3131382c3130352c3131352c3130352c39382c3130352c3130382c3130352c3131362c3132312c312c3132302c34302c302c3138392c3232342c3134362c3136352c362c302c31302c39392c3131342c3130312c39372c3131362c3130312c3130302c39352c39372c3131362c312c3132322c302c302c302c302c3130322c3233332c31352c3138322c33332c302c3138392c3232342c3134362c3136352c362c302c31332c3130382c39372c3131352c3131362c39352c3130392c3131312c3130302c3130352c3130322c3130352c3130312c3130302c312c33392c302c3138392c3232342c3134362c3136352c362c302c352c39392c3130312c3130382c3130382c3131352c312c322c3230302c3134342c3138392c3136312c392c332c302c312c362c312c31322c312c3138392c3232342c3134362c3136352c362c312c382c315d7d2c2264617461626173655f726f775f646f63756d656e745f636f6c6c616273223a7b7d2c2276697369626c655f64617461626173655f766965775f696473223a5b2235656366366161312d643464362d343765342d616630662d336137613966643832393964225d2c2264617461626173655f72656c6174696f6e73223a7b2233393163366132312d633034622d343039622d383234302d653532393361353162343636223a2262623232313137352d313464612d346130352d613039642d353935653432643233353066222c2261333765356432332d353238352d346235622d393732662d316363626632346333353838223a2261643534393335302d363563382d343064302d613362322d663035633866616531386262222c2239643733383436362d313835642d343765302d616631632d393138356361323066323733223a2235656366366161312d643464362d343765342d616630662d336137613966643832393964222c2232633338313638382d623330372d343039622d623231382d323065363963303866313036223a2231386437323538392d383064372d343034312d393334322d356435373266616362376339222c2265633863326236352d343764312d346636632d623430382d313436303461663964653535223a2263666464303362322d623533392d346631642d383463332d336330343234626264333435222c2235346537366136652d303535342d346262332d623466352d646339326337373530653766223a2230353537303765642d313838392d343062342d386464622d363938306263636633616238222c2235633863626566302d313063332d346238632d383231392d653138323364313039636537223a2238613366633632392d616431332d343265662d613264322d663231636562346331626537222c2237323139376235322d666530642d343962382d623438662d616136323732633032393666223a2264666630393733632d393034382d346532332d393639372d303463623134633531386231222c2238326434313761652d386561322d343333392d613661302d623737383937626530303039223a2233623562616633622d383765372d346565322d613037382d326263376664666233663533222c2264306633333166312d373466632d346136302d383139382d623639616638396162633961223a2231376365386333652d386633352d346631622d393835612d373936346564626336616530222c2232613437376165652d613731372d346364302d623538392d373561333337353162393333223a2265376533613336312d326666612d346264642d383666382d653438663831316165336232222c2235393764343563382d353435302d346564332d383533382d376666653963383465363335223a2261323331663763392d366432662d343666322d396663312d633362653763616636616335222c2265376131383830322d313539342d346434342d393534322d333863376234373165626332223a2235666336363966612d383836372d346636642d393866312d636533383735393765616264227d7d\";\n"
  },
  {
    "path": "tests/workspace/quick_note.rs",
    "content": "use std::time::Duration;\n\nuse client_api_test::TestClient;\nuse serde_json::json;\nuse tokio::time;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn quick_note_crud_test() {\n  let client = TestClient::new_user_without_ws_conn().await;\n  let workspace_id = client.workspace_id().await;\n  let mut quick_note_ids: Vec<Uuid> = vec![];\n  for _ in 0..2 {\n    let quick_note = client\n      .api_client\n      .create_quick_note(workspace_id, None)\n      .await\n      .expect(\"create quick note\");\n    quick_note_ids.push(quick_note.id);\n    // To ensure that the creation time is different\n    time::sleep(Duration::from_millis(1)).await;\n  }\n  let _quick_note_id_1 = quick_note_ids[0];\n  let _quick_note_id_2 = quick_note_ids[1];\n  let quick_notes = client\n    .api_client\n    .list_quick_notes(workspace_id, None, None, None)\n    .await\n    .expect(\"list quick notes\");\n  assert_eq!(quick_notes.quick_notes.len(), 2);\n  assert!(!quick_notes.has_more);\n  let mut notes_sorted_by_created_at_asc = quick_notes.quick_notes.clone();\n  notes_sorted_by_created_at_asc.sort_by(|a, b| a.created_at.cmp(&b.created_at));\n\n  let quick_note_id_1 = notes_sorted_by_created_at_asc[0].id;\n  let quick_note_id_2 = notes_sorted_by_created_at_asc[1].id;\n  let data_1 = json!([\n    {\n      \"type\": \"paragraph\",\n      \"delta\": {\n        \"insert\": \"orange\",\n        \"attributes\": {\n          \"bold\": true\n        },\n      },\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 1\n      },\n      \"delta\": {\n        \"insert\": \"apple\",\n        \"attributes\": {\n          \"bold\": true\n        },\n      },\n    },\n  ]);\n  let data_2 = json!([\n    {\n      \"type\": \"paragraph\",\n      \"delta\": {\n        \"insert\": \"banana\",\n        \"attributes\": {\n          \"bold\": true\n        },\n      },\n    },\n    {\n      \"type\": \"heading\",\n      \"data\": {\n        \"level\": 1\n      },\n      \"delta\": {\n        \"insert\": \"melon\",\n        \"attributes\": {\n          \"bold\": true\n        },\n      },\n    },\n  ]);\n  client\n    .api_client\n    .update_quick_note(workspace_id, quick_note_id_2, data_2)\n    .await\n    .expect(\"update quick note\");\n  // To ensure that the update time is different\n  time::sleep(Duration::from_millis(1)).await;\n  client\n    .api_client\n    .update_quick_note(workspace_id, quick_note_id_1, data_1)\n    .await\n    .expect(\"update quick note\");\n  let quick_notes = client\n    .api_client\n    .list_quick_notes(workspace_id, None, None, None)\n    .await\n    .expect(\"list quick notes\");\n  assert_eq!(quick_notes.quick_notes.len(), 2);\n  let quick_notes = client\n    .api_client\n    .list_quick_notes(workspace_id, Some(\"\".to_string()), None, None)\n    .await\n    .expect(\"list quick notes with empty search term\");\n  assert_eq!(quick_notes.quick_notes.len(), 2);\n  let quick_notes_with_offset_and_limit = client\n    .api_client\n    .list_quick_notes(workspace_id, None, Some(1), Some(1))\n    .await\n    .expect(\"list quick notes with offset and limit\");\n  assert_eq!(quick_notes_with_offset_and_limit.quick_notes.len(), 1);\n  assert!(!quick_notes_with_offset_and_limit.has_more);\n  assert_eq!(\n    quick_notes_with_offset_and_limit.quick_notes[0].id,\n    quick_note_id_2\n  );\n  let quick_notes_with_offset_and_limit = client\n    .api_client\n    .list_quick_notes(workspace_id, None, Some(0), Some(1))\n    .await\n    .expect(\"list quick notes with offset and limit\");\n  assert_eq!(quick_notes_with_offset_and_limit.quick_notes.len(), 1);\n  assert!(quick_notes_with_offset_and_limit.has_more);\n  assert_eq!(\n    quick_notes_with_offset_and_limit.quick_notes[0].id,\n    quick_note_id_1\n  );\n  let filtered_quick_notes = client\n    .api_client\n    .list_quick_notes(workspace_id, Some(\"pple\".to_string()), None, None)\n    .await\n    .expect(\"list quick notes with filter\");\n  assert_eq!(filtered_quick_notes.quick_notes.len(), 1);\n  assert_eq!(filtered_quick_notes.quick_notes[0].id, quick_note_id_1);\n  client\n    .api_client\n    .delete_quick_note(workspace_id, quick_note_id_1)\n    .await\n    .expect(\"delete quick note\");\n  let quick_notes = client\n    .api_client\n    .list_quick_notes(workspace_id, None, None, None)\n    .await\n    .expect(\"list quick notes\");\n  assert_eq!(quick_notes.quick_notes.len(), 1);\n  assert_eq!(quick_notes.quick_notes[0].id, quick_note_id_2);\n}\n"
  },
  {
    "path": "tests/workspace/template.rs",
    "content": "use std::collections::HashSet;\n\nuse app_error::ErrorCode;\nuse client_api::entity::{\n  AccountLink, CreateTemplateCategoryParams, CreateTemplateParams, PublishCollabItem,\n  PublishCollabMetadata, TemplateCategoryType, UpdateTemplateCategoryParams, UpdateTemplateParams,\n};\nuse client_api_test::*;\nuse uuid::Uuid;\n\nasync fn get_first_workspace(c: &client_api::Client) -> Uuid {\n  c.get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id\n}\n\n#[tokio::test]\nasync fn test_template_category_crud() {\n  let (authorized_client, _) = generate_unique_registered_user_client().await;\n  let category_name = Uuid::new_v4().to_string();\n  let params = CreateTemplateCategoryParams {\n    name: category_name.clone(),\n    icon: \"icon\".to_string(),\n    bg_color: \"bg_color\".to_string(),\n    description: \"description\".to_string(),\n    category_type: TemplateCategoryType::Feature,\n    priority: 1,\n  };\n  let new_template_category = authorized_client\n    .create_template_category(&params)\n    .await\n    .unwrap();\n  assert_eq!(new_template_category.name, category_name);\n  assert_eq!(new_template_category.icon, params.icon);\n  assert_eq!(new_template_category.bg_color, params.bg_color);\n  assert_eq!(new_template_category.description, params.description);\n  assert_eq!(\n    new_template_category.category_type,\n    TemplateCategoryType::Feature\n  );\n  assert_eq!(new_template_category.priority, 1);\n  let updated_category_name = Uuid::new_v4().to_string();\n  let params = UpdateTemplateCategoryParams {\n    name: updated_category_name.clone(),\n    icon: \"new_icon\".to_string(),\n    bg_color: \"new_bg_color\".to_string(),\n    description: \"new_description\".to_string(),\n    category_type: TemplateCategoryType::UseCase,\n    priority: 2,\n  };\n  let updated_template_category = authorized_client\n    .update_template_category(new_template_category.id, &params)\n    .await\n    .unwrap();\n  assert_eq!(updated_template_category.name, updated_category_name);\n  assert_eq!(updated_template_category.icon, params.icon);\n  assert_eq!(updated_template_category.bg_color, params.bg_color);\n  assert_eq!(updated_template_category.description, params.description);\n  assert_eq!(\n    updated_template_category.category_type,\n    TemplateCategoryType::UseCase\n  );\n  assert_eq!(updated_template_category.priority, 2);\n\n  let guest_client = localhost_client();\n  let template_category = guest_client\n    .get_template_category(new_template_category.id)\n    .await\n    .unwrap();\n  assert_eq!(template_category.name, updated_category_name);\n  assert_eq!(template_category.icon, params.icon);\n  assert_eq!(template_category.bg_color, params.bg_color);\n  assert_eq!(template_category.description, params.description);\n  assert_eq!(\n    template_category.category_type,\n    TemplateCategoryType::UseCase\n  );\n  assert_eq!(template_category.priority, 2);\n\n  let second_category_name = Uuid::new_v4().to_string();\n  let params = CreateTemplateCategoryParams {\n    name: second_category_name.clone(),\n    icon: \"second_icon\".to_string(),\n    bg_color: \"second_bg_color\".to_string(),\n    description: \"second_description\".to_string(),\n    category_type: TemplateCategoryType::Feature,\n    priority: 3,\n  };\n  authorized_client\n    .create_template_category(&params)\n    .await\n    .unwrap();\n  let guest_client = localhost_client();\n  let result = guest_client.create_template_category(&params).await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n\n  let name_search_substr = &second_category_name[0..second_category_name.len() - 1];\n  let category_by_name_search_result = guest_client\n    .get_template_categories(Some(name_search_substr), None)\n    .await\n    .unwrap()\n    .categories;\n  assert_eq!(category_by_name_search_result.len(), 1);\n  assert_eq!(category_by_name_search_result[0].name, second_category_name);\n  let category_by_type_search_result = guest_client\n    .get_template_categories(None, Some(TemplateCategoryType::Feature))\n    .await\n    .unwrap()\n    .categories;\n  // Since the table might not be in a clean state, we can't guarantee that there is only one category of type Feature\n  assert!(!category_by_type_search_result.is_empty());\n  assert!(category_by_type_search_result\n    .iter()\n    .all(|r| r.category_type == TemplateCategoryType::Feature));\n  assert!(category_by_type_search_result\n    .iter()\n    .any(|r| r.name == second_category_name));\n  let result = guest_client\n    .delete_template_category(new_template_category.id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n  authorized_client\n    .delete_template_category(new_template_category.id)\n    .await\n    .unwrap();\n  let result = guest_client\n    .get_template_category(new_template_category.id)\n    .await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn test_template_creator_crud() {\n  let (authorized_client, _) = generate_unique_registered_user_client().await;\n  let account_links = vec![AccountLink {\n    link_type: \"reddit\".to_string(),\n    url: \"reddit_url\".to_string(),\n  }];\n  let creator_name_prefix = Uuid::new_v4().to_string();\n  let creator_name = format!(\"{}-name\", creator_name_prefix);\n  let new_creator = authorized_client\n    .create_template_creator(creator_name.as_str(), \"avatar_url\", account_links)\n    .await\n    .unwrap();\n  assert_eq!(new_creator.name, creator_name);\n  assert_eq!(new_creator.avatar_url, \"avatar_url\");\n  assert_eq!(new_creator.account_links.len(), 1);\n  assert_eq!(new_creator.account_links[0].link_type, \"reddit\");\n  assert_eq!(new_creator.account_links[0].url, \"reddit_url\");\n\n  let guest_client = localhost_client();\n  let result = guest_client.create_template_creator(\"\", \"\", vec![]).await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n\n  let updated_account_links = vec![AccountLink {\n    link_type: \"twitter\".to_string(),\n    url: \"twitter_url\".to_string(),\n  }];\n  let updated_creator_name = format!(\"{}-new_name\", creator_name_prefix);\n  let updated_creator = authorized_client\n    .update_template_creator(\n      new_creator.id,\n      updated_creator_name.as_str(),\n      \"new_avatar_url\",\n      updated_account_links,\n    )\n    .await\n    .unwrap();\n  assert_eq!(updated_creator.name, updated_creator_name);\n  assert_eq!(updated_creator.avatar_url, \"new_avatar_url\");\n  assert_eq!(updated_creator.account_links.len(), 1);\n  assert_eq!(updated_creator.account_links[0].link_type, \"twitter\");\n  assert_eq!(updated_creator.account_links[0].url, \"twitter_url\");\n\n  let creator = guest_client\n    .get_template_creator(new_creator.id)\n    .await\n    .unwrap();\n  assert_eq!(creator.name, updated_creator_name);\n  assert_eq!(creator.avatar_url, \"new_avatar_url\");\n  assert_eq!(creator.account_links.len(), 1);\n  assert_eq!(creator.account_links[0].link_type, \"twitter\");\n  assert_eq!(creator.account_links[0].url, \"twitter_url\");\n\n  let creators = guest_client\n    .get_template_creators(Some(creator_name_prefix).as_deref())\n    .await\n    .unwrap()\n    .creators;\n  assert_eq!(creators.len(), 1);\n\n  let result = guest_client.delete_template_creator(new_creator.id).await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::NotLoggedIn);\n  authorized_client\n    .delete_template_creator(new_creator.id)\n    .await\n    .unwrap();\n  let result = guest_client.get_template_creator(new_creator.id).await;\n  assert!(result.is_err());\n  assert_eq!(result.unwrap_err().code, ErrorCode::RecordNotFound);\n}\n\n#[tokio::test]\nasync fn test_template_crud() {\n  let (authorized_client, _) = generate_unique_registered_user_client().await;\n  let workspace_id = get_first_workspace(&authorized_client).await;\n  let published_view_namespace = uuid::Uuid::new_v4().to_string();\n  authorized_client\n    .set_workspace_publish_namespace(&workspace_id, published_view_namespace.clone())\n    .await\n    .unwrap();\n  let published_view_ids: Vec<Uuid> = (0..4).map(|_| Uuid::new_v4()).collect();\n  let published_collab_items: Vec<PublishCollabItem<TemplateMetadata, &[u8]>> = published_view_ids\n    .iter()\n    .map(|view_id| PublishCollabItem {\n      meta: PublishCollabMetadata {\n        view_id: *view_id,\n        publish_name: view_id.to_string(),\n        metadata: TemplateMetadata {},\n      },\n      data: \"yrs_encoded_data_1\".as_bytes(),\n      comments_enabled: true,\n      duplicate_enabled: true,\n    })\n    .collect();\n\n  authorized_client\n    .publish_collabs::<TemplateMetadata, &[u8]>(&workspace_id, published_collab_items)\n    .await\n    .unwrap();\n\n  let category_prefix = Uuid::new_v4().to_string();\n  let category_1_name = format!(\"{}_1\", category_prefix);\n  let category_2_name = format!(\"{}_2\", category_prefix);\n\n  let creator_1 = authorized_client\n    .create_template_creator(\n      \"template_creator 1\",\n      \"avatar_url\",\n      vec![AccountLink {\n        link_type: \"reddit\".to_string(),\n        url: \"reddit_url\".to_string(),\n      }],\n    )\n    .await\n    .unwrap();\n  let creator_2 = authorized_client\n    .create_template_creator(\n      \"template_creator 2\",\n      \"avatar_url\",\n      vec![AccountLink {\n        link_type: \"facebook\".to_string(),\n        url: \"facebook_url\".to_string(),\n      }],\n    )\n    .await\n    .unwrap();\n  let params = CreateTemplateCategoryParams {\n    name: category_1_name,\n    icon: \"icon\".to_string(),\n    bg_color: \"bg_color\".to_string(),\n    description: \"description\".to_string(),\n    category_type: TemplateCategoryType::Feature,\n    priority: 0,\n  };\n  let category_1 = authorized_client\n    .create_template_category(&params)\n    .await\n    .unwrap();\n\n  let params = CreateTemplateCategoryParams {\n    name: category_2_name,\n    icon: \"icon\".to_string(),\n    bg_color: \"bg_color\".to_string(),\n    description: \"description\".to_string(),\n    category_type: TemplateCategoryType::Feature,\n    priority: 0,\n  };\n  let category_2 = authorized_client\n    .create_template_category(&params)\n    .await\n    .unwrap();\n\n  let template_name_prefix = Uuid::new_v4().to_string();\n  for (index, view_id) in published_view_ids[0..2].iter().enumerate() {\n    let is_new_template = index % 2 == 0;\n    let is_featured = true;\n    let category_id = category_1.id;\n    let params = CreateTemplateParams {\n      view_id: *view_id,\n      name: format!(\"{}-{}\", template_name_prefix, view_id),\n      description: \"description\".to_string(),\n      about: \"about\".to_string(),\n      view_url: \"view_url\".to_string(),\n      category_ids: vec![category_id],\n      creator_id: creator_1.id,\n      is_new_template,\n      is_featured,\n      related_view_ids: vec![],\n    };\n    let template = authorized_client.create_template(&params).await.unwrap();\n    assert_eq!(template.view_id, *view_id);\n    assert_eq!(template.categories.len(), 1);\n    assert_eq!(template.categories[0].id, category_id);\n    assert_eq!(template.creator.id, creator_1.id);\n    assert_eq!(template.creator.name, creator_1.name);\n    assert_eq!(template.creator.account_links.len(), 1);\n    assert_eq!(\n      template.creator.account_links[0].url,\n      creator_1.account_links[0].url\n    );\n    assert!(template.related_templates.is_empty())\n  }\n\n  for (index, view_id) in published_view_ids[2..4].iter().enumerate() {\n    let is_new_template = index % 2 == 0;\n    let is_featured = false;\n    let category_id = category_2.id;\n    let params = CreateTemplateParams {\n      view_id: *view_id,\n      name: format!(\"{}-{}\", template_name_prefix, view_id),\n      description: \"description\".to_string(),\n      about: \"about\".to_string(),\n      view_url: \"view_url\".to_string(),\n      category_ids: vec![category_id],\n      creator_id: creator_2.id,\n      is_new_template,\n      is_featured,\n      related_view_ids: vec![published_view_ids[0]],\n    };\n    let template = authorized_client.create_template(&params).await.unwrap();\n    assert_eq!(template.related_templates.len(), 1);\n    assert_eq!(template.related_templates[0].view_id, published_view_ids[0]);\n    assert_eq!(template.related_templates[0].creator.id, creator_1.id);\n    assert_eq!(template.related_templates[0].categories.len(), 1);\n    assert_eq!(\n      template.related_templates[0].categories[0].id,\n      category_1.id\n    );\n  }\n\n  let guest_client = localhost_client();\n  let templates = guest_client\n    .get_templates(\n      Some(category_2.id),\n      None,\n      None,\n      Some(template_name_prefix.clone()),\n    )\n    .await\n    .unwrap()\n    .templates;\n  let view_ids: HashSet<Uuid> = templates.iter().map(|t| t.template.view_id).collect();\n  assert_eq!(templates.len(), 2);\n  assert!(view_ids.contains(&published_view_ids[2]));\n  assert!(view_ids.contains(&published_view_ids[3]));\n  assert_eq!(\n    templates[0].publish_info.namespace,\n    published_view_namespace\n  );\n\n  let featured_templates = guest_client\n    .get_templates(None, Some(true), None, Some(template_name_prefix.clone()))\n    .await\n    .unwrap()\n    .templates;\n  let featured_view_ids: HashSet<Uuid> = featured_templates\n    .iter()\n    .map(|t| t.template.view_id)\n    .collect();\n  assert_eq!(featured_templates.len(), 2);\n  assert!(featured_view_ids.contains(&published_view_ids[0]));\n  assert!(featured_view_ids.contains(&published_view_ids[1]));\n\n  let new_templates = guest_client\n    .get_templates(None, None, Some(true), Some(template_name_prefix.clone()))\n    .await\n    .unwrap()\n    .templates;\n  let new_view_ids: HashSet<Uuid> = new_templates.iter().map(|t| t.template.view_id).collect();\n  assert_eq!(new_templates.len(), 2);\n  assert!(new_view_ids.contains(&published_view_ids[0]));\n  assert!(new_view_ids.contains(&published_view_ids[2]));\n\n  let template = guest_client\n    .get_template(published_view_ids[3])\n    .await\n    .unwrap();\n  assert_eq!(template.template.view_id, published_view_ids[3]);\n  assert_eq!(template.template.creator.id, creator_2.id);\n  assert_eq!(template.template.categories.len(), 1);\n  assert_eq!(template.template.categories[0].id, category_2.id);\n  assert_eq!(template.template.related_templates.len(), 1);\n  assert_eq!(\n    template.template.related_templates[0].view_id,\n    published_view_ids[0]\n  );\n  assert_eq!(\n    template.publish_info.namespace,\n    published_view_namespace.clone()\n  );\n\n  let params = UpdateTemplateParams {\n    name: format!(\"{}-{}\", template_name_prefix, published_view_ids[3]),\n    description: \"description\".to_string(),\n    about: \"about\".to_string(),\n    view_url: \"view_url\".to_string(),\n    category_ids: vec![category_1.id],\n    creator_id: creator_2.id,\n    is_new_template: false,\n    is_featured: true,\n    related_view_ids: vec![published_view_ids[0]],\n  };\n  authorized_client\n    .update_template(published_view_ids[3], &params)\n    .await\n    .unwrap();\n\n  authorized_client\n    .delete_template(published_view_ids[3])\n    .await\n    .unwrap();\n  let resp = guest_client.get_template(published_view_ids[3]).await;\n  assert!(resp.is_err());\n  assert_eq!(resp.unwrap_err().code, ErrorCode::RecordNotFound);\n}\n\n#[derive(serde::Serialize, serde::Deserialize)]\nstruct TemplateMetadata {}\n"
  },
  {
    "path": "tests/workspace/workspace_crud.rs",
    "content": "use std::collections::HashMap;\n\nuse client_api_test::generate_unique_registered_user_client;\nuse collab_entity::CollabType;\nuse database_entity::dto::QueryCollabParams;\nuse serde_json::json;\nuse shared_entity::dto::workspace_dto::AFDatabaseField;\nuse shared_entity::dto::workspace_dto::CreateWorkspaceParam;\nuse shared_entity::dto::workspace_dto::PatchWorkspaceParam;\n\n#[tokio::test]\nasync fn workspace_list_database() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = c.get_workspaces().await.unwrap()[0].workspace_id;\n\n  {\n    let dbs = c.list_databases(&workspace_id).await.unwrap();\n    assert_eq!(dbs.len(), 1, \"{:?}\", dbs);\n    let todos_db = &dbs[0];\n    assert_eq!(todos_db.views.len(), 1);\n    assert_eq!(todos_db.views[0].name, \"To-dos\");\n    {\n      let db_row_ids = c\n        .list_database_row_ids(&workspace_id, &todos_db.id)\n        .await\n        .unwrap();\n      assert_eq!(db_row_ids.len(), 5, \"{:?}\", db_row_ids);\n    }\n    {\n      let mut db_fields = c\n        .get_database_fields(&workspace_id, &todos_db.id)\n        .await\n        .unwrap();\n\n      // convert to hashset to check for equeality\n      db_fields.sort_by(|a, b| a.id.cmp(&b.id));\n      let mut expected = vec![\n        AFDatabaseField {\n          id: \"wdX8DG\".to_string(),\n          name: \"Multiselect\".to_string(),\n          field_type: \"MultiSelect\".to_string(),\n          type_option: {\n            let mut options = HashMap::new();\n            options.insert(\n              \"content\".to_string(),\n              json!({\n                \"disable_color\": false,\n                \"options\": [\n                  {\"color\": \"Purple\", \"id\": \"4PDn\", \"name\": \"get things done\"},\n                  {\"color\": \"Blue\", \"id\": \"Bpyg\", \"name\": \"self-host\"},\n                  {\"color\": \"Aqua\", \"id\": \"GOQj\", \"name\": \"open source\"},\n                  {\"color\": \"Green\", \"id\": \"BD-T\", \"name\": \"looks great\"},\n                  {\"color\": \"Lime\", \"id\": \"6UxM\", \"name\": \"fast\"},\n                  {\"color\": \"Yellow\", \"id\": \"g2Uq\", \"name\": \"Claude 3\"},\n                  {\"color\": \"Orange\", \"id\": \"Tt-J\", \"name\": \"GPT-4o\"},\n                  {\"color\": \"LightPink\", \"id\": \"5QDY\", \"name\": \"Q&A\"},\n                  {\"color\": \"Pink\", \"id\": \"XYUx\", \"name\": \"news\"},\n                  {\"color\": \"Purple\", \"id\": \"hoZx\", \"name\": \"social\"},\n                ],\n              }),\n            );\n            options\n          },\n          is_primary: false,\n        },\n        AFDatabaseField {\n          id: \"SqwRg1\".to_string(),\n          name: \"Status\".to_string(),\n          field_type: \"SingleSelect\".to_string(),\n          type_option: {\n            let mut options = HashMap::new();\n            options.insert(\n              \"content\".to_string(),\n              json!({\n                \"disable_color\": false,\n                \"options\": [\n                  {\"color\": \"Purple\", \"id\": \"CEZD\", \"name\": \"To Do\"},\n                  {\"color\": \"Orange\", \"id\": \"TznH\", \"name\": \"Doing\"},\n                  {\"color\": \"Yellow\", \"id\": \"__n6\", \"name\": \"✅ Done\"},\n                ],\n              }),\n            );\n            options\n          },\n          is_primary: false,\n        },\n        AFDatabaseField {\n          id: \"phVRgL\".to_string(),\n          name: \"Description\".to_string(),\n          field_type: \"RichText\".to_string(),\n          type_option: {\n            let mut options = HashMap::new();\n            options.insert(\"data\".to_string(), json!(\"\"));\n            options\n          },\n          is_primary: true,\n        },\n        AFDatabaseField {\n          id: \"KinVda\".to_string(),\n          name: \"Tasks\".to_string(),\n          field_type: \"Checklist\".to_string(),\n          type_option: HashMap::new(),\n          is_primary: false,\n        },\n        AFDatabaseField {\n          id: \"3AE6iK\".to_string(),\n          name: \"Last modified\".to_string(),\n          field_type: \"LastEditedTime\".to_string(),\n          type_option: {\n            let mut options = HashMap::new();\n            options.insert(\"date_format\".to_string(), json!(3));\n            options.insert(\"field_type\".to_string(), json!(8));\n            options.insert(\"include_time\".to_string(), json!(true));\n            options.insert(\"time_format\".to_string(), json!(1));\n            options\n          },\n          is_primary: false,\n        },\n      ];\n      expected.sort_by(|a, b| a.id.cmp(&b.id));\n      assert_eq!(db_fields, expected, \"{:#?}\", db_fields);\n    }\n    {\n      let db_row_ids = c\n        .list_database_row_ids_updated(&workspace_id, &todos_db.id, None)\n        .await\n        .unwrap();\n      assert_eq!(db_row_ids.len(), 5, \"{:?}\", db_row_ids);\n    }\n    {\n      let db_row_ids = c\n        .list_database_row_ids(&workspace_id, &todos_db.id)\n        .await\n        .unwrap();\n      assert_eq!(db_row_ids.len(), 5, \"{:?}\", db_row_ids);\n    }\n  }\n}\n\n#[tokio::test]\nasync fn add_and_delete_workspace_for_user() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let newly_added_workspace = c\n    .create_workspace(CreateWorkspaceParam {\n      workspace_name: Some(\"my_workspace\".to_string()),\n      workspace_icon: Some(\"🏡\".to_string()),\n    })\n    .await\n    .unwrap();\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 2);\n\n  let _ = workspaces\n    .iter()\n    .find(|w| {\n      w.workspace_name == \"my_workspace\"\n        && w.icon == \"🏡\"\n        && w.workspace_id == newly_added_workspace.workspace_id\n    })\n    .unwrap();\n\n  // Workspace need to have at least one collab\n  let workspace_id = newly_added_workspace.workspace_id;\n  let _ = c\n    .get_collab(QueryCollabParams::new(\n      workspace_id,\n      CollabType::Folder,\n      workspace_id,\n    ))\n    .await\n    .unwrap();\n\n  c.delete_workspace(&workspace_id).await.unwrap();\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n}\n\n#[tokio::test]\nasync fn test_workspace_rename_and_icon_change() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspace_id = c\n    .get_workspaces()\n    .await\n    .unwrap()\n    .first()\n    .unwrap()\n    .workspace_id;\n  let desired_new_name = \"tom's workspace\";\n\n  {\n    c.patch_workspace(PatchWorkspaceParam {\n      workspace_id,\n      workspace_name: Some(desired_new_name.to_string()),\n      ..Default::default()\n    })\n    .await\n    .expect(\"Failed to rename workspace\");\n\n    let workspaces = c.get_workspaces().await.expect(\"Failed to get workspaces\");\n    let actual_new_name = &workspaces\n      .first()\n      .expect(\"No workspace found\")\n      .workspace_name;\n    assert_eq!(actual_new_name, desired_new_name);\n  }\n\n  {\n    c.patch_workspace(PatchWorkspaceParam {\n      workspace_id,\n      workspace_name: None,\n      ..Default::default()\n    })\n    .await\n    .expect(\"Failed to rename workspace\");\n    let workspaces = c.get_workspaces().await.expect(\"Failed to get workspaces\");\n    let actual_new_name = &workspaces\n      .first()\n      .expect(\"No workspace found\")\n      .workspace_name;\n    assert_eq!(actual_new_name, desired_new_name);\n  }\n  {\n    c.patch_workspace(PatchWorkspaceParam {\n      workspace_id,\n      workspace_icon: Some(\"icon123\".to_string()),\n      ..Default::default()\n    })\n    .await\n    .expect(\"Failed to change icon\");\n    let workspaces = c.get_workspaces().await.expect(\"Failed to get workspaces\");\n    let icon = &workspaces.first().expect(\"No workspace found\").icon;\n    assert_eq!(icon, \"icon123\");\n  }\n  {\n    c.patch_workspace(PatchWorkspaceParam {\n      workspace_id,\n      workspace_name: Some(\"new_name456\".to_string()),\n      workspace_icon: Some(\"new_icon456\".to_string()),\n    })\n    .await\n    .expect(\"Failed to change icon\");\n    let workspaces = c.get_workspaces().await.expect(\"Failed to get workspaces\");\n    let workspace = workspaces.first().expect(\"No workspace found\");\n\n    let icon = workspace.icon.as_str();\n    let name = workspace.workspace_name.as_str();\n    assert_eq!(icon, \"new_icon456\");\n    assert_eq!(name, \"new_name456\");\n  }\n}\n"
  },
  {
    "path": "tests/workspace/workspace_folder.rs",
    "content": "use client_api_test::generate_unique_registered_user_client;\n\n#[tokio::test]\nasync fn get_workpace_folder() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  assert_eq!(workspaces.len(), 1);\n  let workspace_id = workspaces[0].workspace_id;\n\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, None, None)\n    .await\n    .unwrap();\n  assert_eq!(folder_view.name, \"Workspace\");\n  assert_eq!(folder_view.children[0].name, \"General\");\n  assert_eq!(folder_view.children[0].children.len(), 0);\n  let folder_view = c\n    .get_workspace_folder(&workspace_id, Some(2), None)\n    .await\n    .unwrap();\n  assert_eq!(folder_view.name, \"Workspace\");\n  assert_eq!(folder_view.children[0].name, \"General\");\n  assert_eq!(folder_view.children[0].children.len(), 2);\n  let folder_view = c\n    .get_workspace_folder(\n      &workspace_id,\n      Some(1),\n      Some(folder_view.children[0].view_id),\n    )\n    .await\n    .unwrap();\n  assert_eq!(folder_view.children.len(), 2);\n}\n"
  },
  {
    "path": "tests/workspace/workspace_settings.rs",
    "content": "use client_api::Client;\nuse client_api_test::generate_unique_registered_user_client;\nuse database_entity::dto::{AFRole, AFWorkspaceInvitationStatus, AFWorkspaceSettingsChange};\nuse shared_entity::dto::workspace_dto::WorkspaceMemberInvitation;\nuse uuid::Uuid;\n\n#[tokio::test]\nasync fn get_and_set_workspace_by_owner() {\n  let (c, _user) = generate_unique_registered_user_client().await;\n  let workspaces = c.get_workspaces().await.unwrap();\n  let workspace_id = workspaces.first().unwrap().workspace_id.to_string();\n\n  let mut settings = c.get_workspace_settings(&workspace_id).await.unwrap();\n  assert!(\n    !settings.disable_search_indexing,\n    \"indexing should be enabled by default\"\n  );\n\n  settings.disable_search_indexing = true;\n  c.update_workspace_settings(\n    &workspace_id,\n    &AFWorkspaceSettingsChange::new().disable_search_indexing(true),\n  )\n  .await\n  .unwrap();\n\n  let settings = c.get_workspace_settings(&workspace_id).await.unwrap();\n  assert!(settings.disable_search_indexing);\n}\n\n#[tokio::test]\nasync fn get_and_set_workspace_by_non_owner() {\n  // TODO: currently, workspace settings contains only AI preference, which is\n  // better suited as a user setting. Meanwhile, we can permit workspace members\n  // to view the settings.\n  let (alice_client, _alice) = generate_unique_registered_user_client().await;\n  let workspaces = alice_client.get_workspaces().await.unwrap();\n  let alice_workspace_id = workspaces.first().unwrap().workspace_id;\n\n  let (bob_client, bob) = generate_unique_registered_user_client().await;\n\n  invite_user_to_workspace(&alice_workspace_id, &alice_client, &bob_client, &bob.email).await;\n\n  bob_client\n    .get_workspace_settings(&alice_workspace_id.to_string())\n    .await\n    .unwrap();\n\n  bob_client\n    .update_workspace_settings(\n      &alice_workspace_id.to_string(),\n      &AFWorkspaceSettingsChange::new().disable_search_indexing(true),\n    )\n    .await\n    .unwrap();\n}\n\nasync fn invite_user_to_workspace(\n  workspace_id: &Uuid,\n  owner: &Client,\n  member: &Client,\n  member_email: &str,\n) {\n  owner\n    .invite_workspace_members(\n      workspace_id,\n      vec![WorkspaceMemberInvitation {\n        email: member_email.to_string(),\n        role: AFRole::Member,\n        skip_email_send: true,\n        ..Default::default()\n      }],\n    )\n    .await\n    .unwrap();\n\n  // list invitation with pending filter\n  let pending_invs = member\n    .list_workspace_invitations(Some(AFWorkspaceInvitationStatus::Pending))\n    .await\n    .unwrap();\n  assert_eq!(pending_invs.len(), 1);\n\n  // accept invitation\n  let target_invite = pending_invs\n    .iter()\n    .find(|i| i.workspace_id == *workspace_id)\n    .unwrap();\n\n  member\n    .accept_workspace_invitation(target_invite.invite_id.to_string().as_str())\n    .await\n    .unwrap();\n}\n"
  },
  {
    "path": "tests/yrs_version/README.md",
    "content": "The `yrs_version` test is designed to ensure that data encoded\nwith an older version of Yrs can be accurately decoded by the\nnew version of Yrs code. Each time a new version of Yrs is released,\nthis test should be conducted to verify compatibility with older versions.\n\nTests should be added and run before updating to a new version of Yrs."
  },
  {
    "path": "tests/yrs_version/document_test.rs",
    "content": "use crate::yrs_version::util::read_bytes_from_file;\nuse collab::core::collab::{default_client_id, DataSource};\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab_document::document::Document;\n\n/// Load collaboration data that was encoded using Yjs version 0.17.\n#[test]\nfn load_yrs_0172_version_get_started_document_using_current_yrs_version() {\n  let data = read_bytes_from_file(\"get_started_encode_collab_0172\");\n  let encode_collab = EncodedCollab::decode_from_bytes(&data).unwrap();\n\n  let document = Document::open_with_options(\n    CollabOrigin::Empty,\n    DataSource::DocStateV1(encode_collab.doc_state.to_vec()),\n    \"fake_id\",\n    default_client_id(),\n  )\n  .unwrap();\n\n  let document_data = document.get_document_data().unwrap();\n  assert_eq!(document_data.blocks.len(), 25);\n  let first_block = document_data.blocks.get(&document_data.page_id).unwrap();\n  assert_eq!(first_block.id, document_data.page_id);\n\n  let icon_block = document_data.blocks.get(\"a9SSKQKF4-\").unwrap();\n  let icon_data = icon_block.data.get(\"icon\").unwrap().as_str().unwrap();\n  assert_eq!(icon_data, \"🥰\");\n\n  let welcome_to_appflowy = document_data\n    .meta\n    .text_map\n    .as_ref()\n    .unwrap()\n    .get(\"OETXfTYZEw\")\n    .unwrap();\n  assert_eq!(\n    welcome_to_appflowy,\n    r#\"[{\"insert\":\"Welcome to AppFlowy!\"}]\"#\n  );\n}\n"
  },
  {
    "path": "tests/yrs_version/folder_test.rs",
    "content": "use collab::core::collab::default_client_id;\nuse collab::core::origin::CollabOrigin;\nuse collab::entity::EncodedCollab;\nuse collab_folder::Folder;\n\nuse crate::yrs_version::util::read_bytes_from_file;\n\n/// Load collaboration data that was encoded using Yjs version 0.17.\n/// folder structure:\n///   favorite: document3\n///   person-document2\n///   person-document1\n///     - person-document1-1\n///     - person-document1-2\n///   Getting started\n///     - document1\n///     - document2\n///     - document3\n///\n#[test]\nfn load_yrs_0172_version_folder_using_current_yrs_version() {\n  let data = read_bytes_from_file(\"folder_encode_collab_0172\");\n  let encode_collab = EncodedCollab::decode_from_bytes(&data).unwrap();\n  let uid = 322319512080748544;\n\n  let folder = Folder::from_collab_doc_state(\n    CollabOrigin::Empty,\n    encode_collab.into(),\n    \"fake_id\", // just use fake id\n    default_client_id(),\n  )\n  .unwrap();\n\n  let workspace_id = folder.get_workspace_id().unwrap();\n  let views = folder.get_views_belong_to(&workspace_id, uid);\n  assert_eq!(views.len(), 3);\n  assert_eq!(views[0].name, \"person-document2\");\n  assert_eq!(views[1].name, \"person-document1\");\n  assert_eq!(views[2].name, \"Getting started\");\n\n  let view_1_sub_views = folder.get_views_belong_to(&views[1].id, uid);\n  assert_eq!(view_1_sub_views.len(), 2);\n  assert_eq!(view_1_sub_views[0].name, \"person-document1-1\");\n  assert_eq!(view_1_sub_views[1].name, \"person-document1-2\");\n\n  let view_2_sub_views = folder.get_views_belong_to(&views[2].id, uid);\n  assert_eq!(view_2_sub_views.len(), 3);\n  assert_eq!(view_2_sub_views[0].name, \"document1\");\n  assert_eq!(view_2_sub_views[1].name, \"document2\");\n  assert_eq!(view_2_sub_views[2].name, \"document3\");\n\n  let favorite_section_items = folder.get_my_favorite_sections(uid);\n  assert_eq!(favorite_section_items.len(), 1);\n  assert_eq!(view_2_sub_views[2].id, favorite_section_items[0].id);\n}\n"
  },
  {
    "path": "tests/yrs_version/mod.rs",
    "content": "mod document_test;\nmod folder_test;\nmod util;\n"
  },
  {
    "path": "tests/yrs_version/util.rs",
    "content": "use std::fs::File;\nuse std::io::Read;\n\npub(crate) fn read_bytes_from_file(file_name: &str) -> Vec<u8> {\n  let mut file = File::open(format!(\"./tests/yrs_version/files/{}\", file_name)).unwrap();\n  let mut buffer = Vec::new();\n  file.read_to_end(&mut buffer).unwrap();\n  buffer\n}\n"
  },
  {
    "path": "xtask/Cargo.toml",
    "content": "[package]\nname = \"xtask\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[dependencies]\nanyhow = \"1.0\"\ntokio = { version = \"1\", features = [\"full\"] }\nfutures = \"0.3.31\""
  },
  {
    "path": "xtask/src/main.rs",
    "content": "use anyhow::{anyhow, Context, Result};\nuse std::process::Stdio;\nuse tokio::process::Command;\nuse tokio::select;\nuse tokio::time::{sleep, Duration};\n\n/// Run servers:\n/// cargo run --package xtask\n///\n/// Run servers and stress tests:\n/// cargo run --package xtask -- --stress-test\n///\n/// Run without appflowy-worker:\n/// cargo run --package xtask -- --no-worker\n///\n/// Run with optimizations (recommended for development):\n/// cargo run --package xtask -- --ci\n///\n/// Run with full optimizations (production):\n/// cargo run --package xtask -- --release\n///\n/// Run with profiling enabled:\n/// cargo run --package xtask -- --profiling\n///\n/// Combine flags:\n/// cargo run --package xtask -- --ci --stress-test --no-worker\n///\n/// Note: test start with 'stress_test' will be run as stress tests\n#[tokio::main]\nasync fn main() -> Result<()> {\n  let is_stress_test = std::env::args().any(|arg| arg == \"--stress-test\");\n  let no_worker = std::env::args().any(|arg| arg == \"--no-worker\");\n  let target_dir = \"./target\";\n  std::env::set_var(\"CARGO_TARGET_DIR\", target_dir);\n\n  // Optimize Cargo for faster builds\n  std::env::set_var(\"CARGO_INCREMENTAL\", \"1\");\n\n  // Only use sccache if it's available\n  if Command::new(\"sccache\")\n    .arg(\"--version\")\n    .output()\n    .await\n    .is_ok()\n  {\n    std::env::set_var(\"RUSTC_WRAPPER\", \"sccache\");\n    println!(\"Using sccache for faster compilation\");\n  }\n\n  // Add profile flags for optimized performance\n  let profile_flags = if std::env::args().any(|arg| arg == \"--release\") {\n    vec![\"--profile\", \"release\"] // Full optimization with LTO\n  } else if std::env::args().any(|arg| arg == \"--ci\") {\n    vec![\"--profile\", \"ci\"] // Faster compile, still optimized\n  } else if std::env::args().any(|arg| arg == \"--profiling\") {\n    vec![\"--profile\", \"profiling\"] // Release + debug info\n  } else {\n    vec![] // Default dev profile\n  };\n\n  let appflowy_cloud_bin_name = \"appflowy_cloud\";\n  let worker_bin_name = \"appflowy_worker\";\n\n  // Show which profile is being used\n  let profile_name = if profile_flags.contains(&\"release\") {\n    \"release (full optimization + LTO)\"\n  } else if profile_flags.contains(&\"ci\") {\n    \"ci (fast compile + optimized)\"\n  } else if profile_flags.contains(&\"profiling\") {\n    \"profiling (release + debug info)\"\n  } else {\n    \"dev (fastest compile)\"\n  };\n  println!(\"Using profile: {}\", profile_name);\n\n  if no_worker {\n    println!(\"Worker disabled - running without appflowy-worker\");\n  }\n\n  // Step 1: Kill existing processes\n  let kill_cloud_result = kill_existing_process(appflowy_cloud_bin_name);\n  let kill_worker_result = if no_worker {\n    // Still kill existing worker processes if they exist, even if we won't start a new one\n    kill_existing_process(worker_bin_name)\n  } else {\n    kill_existing_process(worker_bin_name)\n  };\n\n  let (kill_result1, kill_result2) = tokio::join!(kill_cloud_result, kill_worker_result);\n  kill_result1?;\n  kill_result2?;\n\n  // Step 2: Start servers\n  if no_worker {\n    println!(\"Starting appflowy-cloud only...\");\n  } else {\n    println!(\"Starting servers in parallel...\");\n  }\n\n  // Prepare args with profile flags if specified\n  let mut cloud_args = vec![\"run\"];\n  cloud_args.extend(&profile_flags);\n  cloud_args.extend(&[\"--features\", \"history, use_actix_cors\"]);\n\n  let appflowy_cloud_handle = tokio::spawn(async move {\n    let cmd = spawn_server(\"cargo\", &cloud_args, appflowy_cloud_bin_name)?;\n    wait_for_readiness(appflowy_cloud_bin_name).await?;\n    Ok::<_, anyhow::Error>(cmd)\n  });\n\n  let appflowy_worker_handle = if no_worker {\n    None\n  } else {\n    let mut worker_args = vec![\"run\"];\n    worker_args.extend(&profile_flags);\n    worker_args.extend(&[\"--manifest-path\", \"./services/appflowy-worker/Cargo.toml\"]);\n\n    Some(tokio::spawn(async move {\n      let cmd = spawn_server(\"cargo\", &worker_args, worker_bin_name)?;\n      wait_for_readiness(worker_bin_name).await?;\n      Ok::<_, anyhow::Error>(cmd)\n    }))\n  };\n\n  // Wait for servers to be ready\n  let mut appflowy_cloud_cmd = appflowy_cloud_handle.await??;\n  let appflowy_worker_cmd = if let Some(handle) = appflowy_worker_handle {\n    Some(handle.await??)\n  } else {\n    None\n  };\n\n  if no_worker {\n    println!(\"AppFlowy Cloud is up and running (worker disabled).\");\n  } else {\n    println!(\"All servers are up and running.\");\n  }\n\n  // Step 3: Run stress tests if flag is set\n  let stress_test_cmd = if is_stress_test {\n    println!(\"Running stress tests (tests starting with 'stress_test')...\");\n    Some(\n      Command::new(\"cargo\")\n        .args([\"test\", \"stress_test\", \"--\", \"--nocapture\"])\n        .stdout(Stdio::inherit())\n        .stderr(Stdio::inherit())\n        .spawn()\n        .context(\"Failed to start stress test process\")?,\n    )\n  } else {\n    None\n  };\n\n  // Step 4: Monitor processes\n  match appflowy_worker_cmd {\n    Some(mut worker_cmd) => {\n      // Monitor both cloud and worker\n      select! {\n          status = appflowy_cloud_cmd.wait() => {\n              handle_process_exit(status?, appflowy_cloud_bin_name)?;\n          },\n          status = worker_cmd.wait() => {\n              handle_process_exit(status?, worker_bin_name)?;\n          },\n          status = async {\n              if let Some(mut stress_cmd) = stress_test_cmd {\n                  stress_cmd.wait().await\n              } else {\n                  futures::future::pending().await\n              }\n          } => {\n              if is_stress_test {\n                  handle_process_exit(status?, \"cargo test stress_test\")?;\n              }\n          },\n      }\n    },\n    None => {\n      // Monitor only cloud (no worker)\n      select! {\n          status = appflowy_cloud_cmd.wait() => {\n              handle_process_exit(status?, appflowy_cloud_bin_name)?;\n          },\n          status = async {\n              if let Some(mut stress_cmd) = stress_test_cmd {\n                  stress_cmd.wait().await\n              } else {\n                  futures::future::pending().await\n              }\n          } => {\n              if is_stress_test {\n                  handle_process_exit(status?, \"cargo test stress_test\")?;\n              }\n          },\n      }\n    },\n  }\n\n  Ok(())\n}\n\nfn spawn_server(command: &str, args: &[&str], name: &str) -> Result<tokio::process::Child> {\n  println!(\"Spawning {} process...\", name,);\n  let mut cmd = Command::new(command);\n  cmd.args(args);\n  cmd\n    .spawn()\n    .context(format!(\"Failed to start {} process\", name))\n}\n\nasync fn kill_existing_process(process_identifier: &str) -> Result<()> {\n  let _ = Command::new(\"pkill\")\n    .arg(\"-f\")\n    .arg(process_identifier)\n    .output()\n    .await\n    .context(\"Failed to kill existing processes\")?;\n  println!(\"Killed existing instances of {}\", process_identifier);\n  Ok(())\n}\n\nfn handle_process_exit(status: std::process::ExitStatus, process_name: &str) -> Result<()> {\n  if status.success() {\n    println!(\"handle_process_exit: {} exited normally.\", process_name);\n    Ok(())\n  } else {\n    Err(anyhow!(\n      \"handle_process_exit: {} process failed: {}\",\n      process_name,\n      status\n    ))\n  }\n}\n\nasync fn wait_for_readiness(process_name: &str) -> Result<()> {\n  println!(\"Waiting for {} to be ready...\", process_name);\n  sleep(Duration::from_secs(3)).await;\n  println!(\"{} is ready.\", process_name);\n  Ok(())\n}\n"
  }
]