diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml
index 483f3dbeee..948fa6c0c1 100644
--- a/.github/workflows/ai-moderator.yml
+++ b/.github/workflows/ai-moderator.yml
@@ -25,6 +25,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: AI Moderator
- uses: github/ai-moderator@v1
+ uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml
index e0eb0de98d..0151c2f9c1 100644
--- a/.github/workflows/auto-label-issue.yml
+++ b/.github/workflows/auto-label-issue.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
- uses: github/issue-labeler@v3.4
+ uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4
with:
configuration-path: .github/labeler.yml
enable-versioned-regex: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8cc3b3e113..eb58f7f8cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,7 +32,7 @@ jobs:
actions: read
security-events: write
contents: read
- uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3"
+ uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
security:
name: Checks / Image
@@ -43,13 +43,13 @@ jobs:
security-events: write
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: 'recursive'
- name: Build the Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: false
@@ -58,7 +58,7 @@ jobs:
target: production
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'pr_image:${{ github.sha }}'
format: 'sarif'
@@ -66,7 +66,7 @@ jobs:
severity: 'CRITICAL,HIGH'
- name: Run Trivy vulnerability scanner on source code
- uses: aquasecurity/trivy-action@0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
@@ -76,14 +76,14 @@ jobs:
skip-setup-trivy: true
- name: Upload image scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
category: 'trivy-image'
- name: Upload source code scan results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
@@ -94,10 +94,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
@@ -127,7 +127,7 @@ jobs:
if: github.event_name == 'pull_request'
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -144,10 +144,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
tools: composer:v2
@@ -157,7 +157,7 @@ jobs:
run: composer install --prefer-dist --no-progress --ignore-platform-reqs
- name: Cache PHPStan result cache
- uses: actions/cache@v4
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .phpstan-cache
key: phpstan-${{ github.sha }}
@@ -172,10 +172,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup PHP
- uses: shivammathur/setup-php@v2
+ uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
with:
php-version: '8.3'
extensions: swoole
@@ -193,10 +193,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
@@ -212,7 +212,7 @@ jobs:
steps:
- name: Generate matrix
id: generate
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB'];
@@ -253,28 +253,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push Appwrite
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
push: true
@@ -297,16 +297,16 @@ jobs:
packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -327,7 +327,7 @@ jobs:
run: docker compose exec -T appwrite vars
- name: Run Unit Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -350,16 +350,16 @@ jobs:
packages: read
steps:
- name: checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -385,7 +385,7 @@ jobs:
done
- name: Run General Tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -445,26 +445,26 @@ jobs:
]
include:
- service: Databases
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
paratest_processes: 3
timeout_minutes: 30
- service: Sites
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
- service: Functions
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
- service: Avatars
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
- service: Realtime
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
- service: TablesDB
- runner: blacksmith-4vcpu-ubuntu-2404
+ runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g
paratest_processes: 3
timeout_minutes: 30
- service: Migrations
paratest_processes: 1
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set environment
run: |
@@ -488,13 +488,13 @@ jobs:
fi
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -525,7 +525,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -573,18 +573,18 @@ jobs:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -607,7 +607,7 @@ jobs:
docker compose up -d --quiet-pull --wait
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -640,16 +640,16 @@ jobs:
mode: ${{ fromJSON(needs.matrix.outputs.modes) }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -679,7 +679,7 @@ jobs:
done
- name: Run tests
- uses: itznotabug/php-retry@v3
+ uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3
with:
max_attempts: 2
retry_wait_seconds: 60
@@ -711,18 +711,18 @@ jobs:
packages: read
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Login to Docker Hub
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
- uses: docker/login-action@v4
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -735,7 +735,7 @@ jobs:
docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after
- name: Setup k6
- uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2
+ uses: grafana/setup-k6-action@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1
with:
k6-version: ${{ env.K6_VERSION }}
@@ -774,7 +774,7 @@ jobs:
- name: Benchmark before
if: steps.benchmark_before_start.outcome == 'success'
continue-on-error: true
- uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
@@ -826,7 +826,7 @@ jobs:
- name: Benchmark after
id: benchmark_after
continue-on-error: true
- uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d
+ uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0
env:
APPWRITE_ENDPOINT: 'http://localhost/v1'
APPWRITE_BENCHMARK_ITERATIONS: '5'
@@ -846,7 +846,7 @@ jobs:
- name: Comment on PR
if: always()
- uses: actions/github-script@v8
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }}
BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }}
@@ -856,7 +856,7 @@ jobs:
await comment({ github, context, core });
- name: Save results
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: benchmark-results
diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml
index 4b6b13d35d..e4f28816be 100644
--- a/.github/workflows/cleanup-cache.yml
+++ b/.github/workflows/cleanup-cache.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Cleanup
run: |
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 7edfde0aae..cb9b09b496 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -34,7 +34,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -47,14 +47,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -68,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c4289678bb..0a49f658ac 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -10,13 +10,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Build the Docker image
run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest
- name: Run Trivy vulnerability scanner on image
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: 'appwrite_image:latest'
format: 'sarif'
@@ -24,7 +24,7 @@ jobs:
ignore-unfixed: 'false'
severity: 'CRITICAL,HIGH'
- name: Upload Docker Image Scan Results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-image-results.sarif') != ''
with:
sarif_file: 'trivy-image-results.sarif'
@@ -35,16 +35,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Trivy vulnerability scanner on filesystem
- uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
format: 'sarif'
output: 'trivy-fs-results.sarif'
severity: 'CRITICAL,HIGH'
- name: Upload Code Scan Results
- uses: github/codeql-action/upload-sarif@v4
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
if: always() && hashFiles('trivy-fs-results.sarif') != ''
with:
sarif_file: 'trivy-fs-results.sarif'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 692861d44d..68ab657213 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -12,33 +12,33 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/cloud
tags: |
type=ref,event=tag
- name: Build & Publish to DockerHub
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84fc4c9fba..ed4e46d811 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -20,20 +20,20 @@ jobs:
submodules: recursive
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v4
+ uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: appwrite/appwrite
tags: |
@@ -42,7 +42,7 @@ jobs:
type=semver,pattern={{major}}
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml
index f81346a7d1..dacc37a64a 100644
--- a/.github/workflows/sdk-preview.yml
+++ b/.github/workflows/sdk-preview.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set SDK type
id: set-sdk
@@ -49,7 +49,7 @@ jobs:
docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no
sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml
index 6f377354d5..85c76bacd3 100644
--- a/.github/workflows/specs.yml
+++ b/.github/workflows/specs.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 6e4a8ba73b..73b767aafe 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v10
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days."
diff --git a/app/config/errors.php b/app/config/errors.php
index 0293be3212..e7b6839a20 100644
--- a/app/config/errors.php
+++ b/app/config/errors.php
@@ -1246,6 +1246,26 @@ return [
'description' => 'The specified database type is not supported for CSV import or export operations.',
'code' => 400,
],
+ Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED,
+ 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [
+ 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND,
+ 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.',
+ 'code' => 404,
+ ],
+ Exception::MIGRATION_SOURCE_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID,
+ 'description' => 'The migration source type is invalid. Use one of the supported source types.',
+ 'code' => 400,
+ ],
+ Exception::MIGRATION_DESTINATION_TYPE_INVALID => [
+ 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID,
+ 'description' => 'The migration destination type is invalid. Use one of the supported destination types.',
+ 'code' => 400,
+ ],
/** Realtime */
Exception::REALTIME_MESSAGE_FORMAT_INVALID => [
diff --git a/app/config/sdks.php b/app/config/sdks.php
index e89265b05e..cbfe76dce4 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -320,6 +320,26 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php
index 1436871186..a512fd92aa 100644
--- a/app/controllers/api/account.php
+++ b/app/controllers/api/account.php
@@ -359,15 +359,15 @@ Http::post('/v1/account')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -1736,15 +1736,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -1878,15 +1878,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
$failureRedirect(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
$failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
$failureRedirect(Exception::USER_EMAIL_FREE);
}
@@ -2247,15 +2247,15 @@ Http::post('/v1/account/tokens/magic-url')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -2569,15 +2569,15 @@ Http::post('/v1/account/tokens/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -3484,15 +3484,15 @@ Http::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_INVALID_EMAIL);
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php
index 2103948c33..796a40d5b8 100644
--- a/app/controllers/api/users.php
+++ b/app/controllers/api/users.php
@@ -131,15 +131,15 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
@@ -1558,15 +1558,15 @@ Http::patch('/v1/users/:userId/email')
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php
index c9e4f8b47d..14ffdc059f 100644
--- a/app/controllers/shared/api.php
+++ b/app/controllers/shared/api.php
@@ -3,7 +3,6 @@
use Appwrite\Auth\Key;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Bus\Events\RequestCompleted;
-use Appwrite\Event\Build;
use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
@@ -20,6 +19,8 @@ use Appwrite\Event\Webhook;
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Functions\EventProcessor;
+use Appwrite\Platform\Modules\Storage\Config\CacheControl;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\SDK\Method;
use Appwrite\Usage\Context;
use Appwrite\Utopia\Database\Documents\User;
@@ -489,7 +490,6 @@ Http::init()
->inject('auditContext')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
->inject('usage')
->inject('queueForFunctions')
->inject('queueForMails')
@@ -503,7 +503,8 @@ Http::init()
->inject('telemetry')
->inject('platform')
->inject('authorization')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) {
+ ->inject('cacheControlForStorage')
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) {
$response->setUser($user);
$request->setUser($user);
@@ -618,12 +619,10 @@ Http::init()
$queueForDatabase->setProject($project);
$queueForMessaging->setProject($project);
$queueForFunctions->setProject($project);
- $queueForBuilds->setProject($project);
$queueForMails->setProject($project);
/* Auto-set platforms */
$queueForFunctions->setPlatform($platform);
- $queueForBuilds->setPlatform($platform);
$queueForMails->setPlatform($platform);
$useCache = $route->getLabel('cache', false);
@@ -643,6 +642,7 @@ Http::init()
$data = $cache->load($key, $timestamp);
if (! empty($data) && ! $cacheLog->isEmpty()) {
+ $cacheControl = \sprintf('private, max-age=%d', $timestamp);
$parts = explode('/', $cacheLog->getAttribute('resourceType', ''));
$type = $parts[0];
@@ -695,6 +695,21 @@ Http::init()
])));
}
}
+
+ if ($isImageTransformation) {
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_CACHE,
+ user: $user,
+ maxAge: $timestamp,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ cacheLog: $cacheLog,
+ route: $route,
+ ));
+ }
}
$accessedAt = $cacheLog->getAttribute('accessedAt', '');
@@ -707,7 +722,7 @@ Http::init()
}
$response
- ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp))
+ ->addHeader('Cache-Control', $cacheControl)
->addHeader('X-Appwrite-Cache', 'hit')
->setContentType($cacheLog->getAttribute('mimeType'));
$storageCacheOperationsCounter->add(1, ['result' => 'hit']);
@@ -800,7 +815,6 @@ Http::shutdown()
->inject('publisherForUsage')
->inject('queueForDeletes')
->inject('queueForDatabase')
- ->inject('queueForBuilds')
->inject('queueForMessaging')
->inject('queueForFunctions')
->inject('queueForWebhooks')
@@ -812,7 +826,7 @@ Http::shutdown()
->inject('bus')
->inject('apiKey')
->inject('mode')
- ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
+ ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) {
$responsePayload = $response->getPayload();
@@ -961,10 +975,6 @@ Http::shutdown()
$queueForDatabase->trigger();
}
- if (! empty($queueForBuilds->getType())) {
- $queueForBuilds->trigger();
- }
-
if (! empty($queueForMessaging->getType())) {
$queueForMessaging->trigger();
}
diff --git a/app/init/resources.php b/app/init/resources.php
index 22d9d943f7..28e3231059 100644
--- a/app/init/resources.php
+++ b/app/init/resources.php
@@ -2,12 +2,14 @@
use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Audit as AuditPublisher;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Publisher\Certificate as CertificatePublisher;
use Appwrite\Event\Publisher\Execution as ExecutionPublisher;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher;
use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher;
use Appwrite\Event\Publisher\Usage as UsagePublisher;
+use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl;
use Appwrite\Utopia\Database\Documents\User;
use Executor\Executor;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
@@ -112,6 +114,10 @@ $container->set('publisherForStatsResources', fn (Publisher $publisher) => new S
$publisher,
new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME))
), ['publisher']);
+$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher(
+ $publisher,
+ new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
+), ['publisher']);
/**
* Platform configuration
@@ -198,6 +204,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) {
return $cache;
}, ['pools', 'telemetry']);
+$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string {
+ return \sprintf('private, max-age=%d', $config->maxAge);
+});
+
$container->set('redis', function () {
$host = System::getEnv('_APP_REDIS_HOST', 'localhost');
$port = System::getEnv('_APP_REDIS_PORT', 6379);
diff --git a/app/init/resources/request.php b/app/init/resources/request.php
index 2fadf0702f..0aa16ece23 100644
--- a/app/init/resources/request.php
+++ b/app/init/resources/request.php
@@ -4,7 +4,6 @@ use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Auth\Key;
use Appwrite\Databases\TransactionState;
-use Appwrite\Event\Build;
use Appwrite\Event\Context\Audit as AuditContext;
use Appwrite\Event\Database as EventDatabase;
use Appwrite\Event\Delete;
@@ -126,9 +125,6 @@ return function (Container $container): void {
$container->set('queueForMails', function (Publisher $publisher) {
return new Mail($publisher);
}, ['publisher']);
- $container->set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
$container->set('queueForDatabase', function (Publisher $publisher) {
return new EventDatabase($publisher);
}, ['publisher']);
diff --git a/app/init/worker/message.php b/app/init/worker/message.php
index 17796fadcd..1469934ad4 100644
--- a/app/init/worker/message.php
+++ b/app/init/worker/message.php
@@ -1,6 +1,5 @@
set('queueForBuilds', function (Publisher $publisher) {
- return new Build($publisher);
- }, ['publisher']);
-
$container->set('queueForDeletes', function (Publisher $publisher) {
return new Delete($publisher);
}, ['publisher']);
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index 1bf36b7f6d..6ce1fb5cea 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -881,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -909,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
- _APP_ENV
- _APP_WORKER_PER_CORE
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
diff --git a/composer.json b/composer.json
index 9a84be6111..55106d33d1 100644
--- a/composer.json
+++ b/composer.json
@@ -68,11 +68,11 @@
"utopia-php/dns": "1.6.*",
"utopia-php/dsn": "0.2.1",
"utopia-php/http": "0.34.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*",
"utopia-php/image": "0.8.*",
"utopia-php/locale": "0.8.*",
- "utopia-php/logger": "0.6.*",
+ "utopia-php/logger": "0.8.*",
"utopia-php/messaging": "0.22.*",
"utopia-php/migration": "1.*",
"utopia-php/platform": "0.13.*",
diff --git a/composer.lock b/composer.lock
index bbf0d59a96..41242fa1c6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ec2ad489c60f0102f0dfab223b6d1fe4",
+ "content-hash": "4ef65b015dba97e91f6571b061787653",
"packages": [
{
"name": "adhocore/jwt",
@@ -2641,16 +2641,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -2663,7 +2663,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2688,7 +2688,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2699,12 +2699,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/http-client",
@@ -2809,16 +2813,16 @@
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -2831,7 +2835,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -2867,7 +2871,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -2878,12 +2882,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "symfony/polyfill-mbstring",
@@ -3212,16 +3220,16 @@
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -3239,7 +3247,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -3275,7 +3283,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -3295,7 +3303,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "tbachert/spi",
@@ -3403,21 +3411,21 @@
},
{
"name": "utopia-php/agents",
- "version": "1.2.1",
+ "version": "1.2.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/agents.git",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e"
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e",
- "reference": "052227953678a30ecc4b5467401fcb0b2386471e",
+ "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634",
+ "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634",
"shasum": ""
},
"require": {
"php": ">=8.3",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1.0"
},
"require-dev": {
"laravel/pint": "^1.18",
@@ -3450,9 +3458,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/agents/issues",
- "source": "https://github.com/utopia-php/agents/tree/1.2.1"
+ "source": "https://github.com/utopia-php/agents/tree/1.2.2"
},
- "time": "2026-02-24T06:03:55+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/analytics",
@@ -3502,22 +3510,22 @@
},
{
"name": "utopia-php/audit",
- "version": "2.2.2",
+ "version": "2.2.3",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/audit.git",
- "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57"
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57",
- "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57",
+ "url": "https://api.github.com/repos/utopia-php/audit/zipball/95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
+ "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/database": "5.*",
- "utopia-php/fetch": "0.5.*",
+ "utopia-php/fetch": "^1.1",
"utopia-php/validators": "0.2.*"
},
"require-dev": {
@@ -3545,9 +3553,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/audit/issues",
- "source": "https://github.com/utopia-php/audit/tree/2.2.2"
+ "source": "https://github.com/utopia-php/audit/tree/2.2.3"
},
- "time": "2026-05-04T06:48:58+00:00"
+ "time": "2026-05-08T10:38:23+00:00"
},
{
"name": "utopia-php/auth",
@@ -4172,22 +4180,21 @@
},
{
"name": "utopia-php/emails",
- "version": "0.6.9",
+ "version": "0.6.10",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/emails.git",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf"
+ "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
- "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf",
+ "url": "https://api.github.com/repos/utopia-php/emails/zipball/2e397754ce68c2ba918564b9f31d9923c0a90429",
+ "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429",
"shasum": ""
},
"require": {
"php": ">=8.0",
"utopia-php/domains": "^1.0",
- "utopia-php/fetch": "^0.5",
"utopia-php/validators": "0.*"
},
"require-dev": {
@@ -4195,7 +4202,8 @@
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.3",
"utopia-php/cli": "^0.22",
- "utopia-php/console": "0.*"
+ "utopia-php/console": "0.*",
+ "utopia-php/fetch": "^1.1"
},
"type": "library",
"autoload": {
@@ -4227,22 +4235,22 @@
],
"support": {
"issues": "https://github.com/utopia-php/emails/issues",
- "source": "https://github.com/utopia-php/emails/tree/0.6.9"
+ "source": "https://github.com/utopia-php/emails/tree/0.6.10"
},
- "time": "2026-03-14T13:52:56+00:00"
+ "time": "2026-05-08T10:16:22+00:00"
},
{
"name": "utopia-php/fetch",
- "version": "0.5.1",
+ "version": "1.1.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/fetch.git",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd"
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd",
- "reference": "a96a010e1c273f3888765449687baf58cbc61fcd",
+ "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5",
+ "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5",
"shasum": ""
},
"require": {
@@ -4251,7 +4259,8 @@
"require-dev": {
"laravel/pint": "^1.5.0",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^9.5"
+ "phpunit/phpunit": "^9.5",
+ "swoole/ide-helper": "^6.0"
},
"type": "library",
"autoload": {
@@ -4266,9 +4275,9 @@
"description": "A simple library that provides an interface for making HTTP Requests.",
"support": {
"issues": "https://github.com/utopia-php/fetch/issues",
- "source": "https://github.com/utopia-php/fetch/tree/0.5.1"
+ "source": "https://github.com/utopia-php/fetch/tree/1.1.2"
},
- "time": "2025-12-18T16:25:10+00:00"
+ "time": "2026-04-29T11:19:19+00:00"
},
{
"name": "utopia-php/http",
@@ -4426,20 +4435,21 @@
},
{
"name": "utopia-php/logger",
- "version": "0.6.2",
+ "version": "0.8.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/logger.git",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d"
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d",
- "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d",
+ "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b",
+ "reference": "132236c42222cd614cb882938a48f8729ef3118b",
"shasum": ""
},
"require": {
- "php": ">=8.0"
+ "php": ">=8.1",
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.2.*",
@@ -4474,9 +4484,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/logger/issues",
- "source": "https://github.com/utopia-php/logger/tree/0.6.2"
+ "source": "https://github.com/utopia-php/logger/tree/0.8.0"
},
- "time": "2024-10-14T16:02:49+00:00"
+ "time": "2026-05-05T06:04:27+00:00"
},
{
"name": "utopia-php/messaging",
@@ -4531,16 +4541,16 @@
},
{
"name": "utopia-php/migration",
- "version": "1.10.0",
+ "version": "1.10.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea"
+ "reference": "211d01b90ccab9729029151c6c61f543bd755c2e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/55f4863d690e775f44fec3cae4bd1f4491fed5ea",
- "reference": "55f4863d690e775f44fec3cae4bd1f4491fed5ea",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/211d01b90ccab9729029151c6c61f543bd755c2e",
+ "reference": "211d01b90ccab9729029151c6c61f543bd755c2e",
"shasum": ""
},
"require": {
@@ -4580,9 +4590,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/migration/issues",
- "source": "https://github.com/utopia-php/migration/tree/1.10.0"
+ "source": "https://github.com/utopia-php/migration/tree/1.10.2"
},
- "time": "2026-05-06T04:35:32+00:00"
+ "time": "2026-05-08T06:25:47+00:00"
},
{
"name": "utopia-php/mongo",
@@ -5228,23 +5238,23 @@
},
{
"name": "utopia-php/vcs",
- "version": "3.2.0",
+ "version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3"
+ "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3",
- "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3",
+ "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03ccd12b75d67d29094eb760b468fddde4b6b5e5",
+ "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5",
"shasum": ""
},
"require": {
"adhocore/jwt": "^1.1",
"php": ">=8.0",
"utopia-php/cache": "1.0.*",
- "utopia-php/fetch": "0.5.*"
+ "utopia-php/fetch": "^1.1"
},
"require-dev": {
"laravel/pint": "1.*.*",
@@ -5271,9 +5281,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
- "source": "https://github.com/utopia-php/vcs/tree/3.2.0"
+ "source": "https://github.com/utopia-php/vcs/tree/3.2.1"
},
- "time": "2026-04-08T16:00:31+00:00"
+ "time": "2026-05-08T10:13:53+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5466,16 +5476,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.27.5",
+ "version": "1.28.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb"
+ "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e363fffd220172c5f1a5032038fa3fdafeeb2dfb",
+ "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb",
"shasum": ""
},
"require": {
@@ -5511,9 +5521,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
- "source": "https://github.com/appwrite/sdk-generator/tree/1.27.5"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.28.0"
},
- "time": "2026-05-05T12:09:40+00:00"
+ "time": "2026-05-08T03:37:44+00:00"
},
{
"name": "brianium/paratest",
diff --git a/docker-compose.yml b/docker-compose.yml
index c7a1f69b22..5d674cf46b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1118,6 +1118,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1149,6 +1156,13 @@ services:
- _APP_WORKER_PER_CORE
- _APP_POOL_ADAPTER
- _APP_OPENSSL_KEY_V1
+ - _APP_OPTIONS_FORCE_HTTPS
+ - _APP_DOMAIN
+ - _APP_CONSOLE_DOMAIN
+ - _APP_DOMAIN_FUNCTIONS
+ - _APP_DOMAIN_SITES
+ - _APP_MIGRATION_HOST
+ - _APP_CONSOLE_SCHEMA
- _APP_REDIS_HOST
- _APP_REDIS_PORT
- _APP_REDIS_USER
@@ -1498,4 +1512,4 @@ volumes:
appwrite-sites:
appwrite-builds:
appwrite-config:
- appwrite-models:
\ No newline at end of file
+ appwrite-models:
diff --git a/docs/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md
index eea1795a3e..b614c2ad13 100644
--- a/docs/references/vcs/list-repository-branches.md
+++ b/docs/references/vcs/list-repository-branches.md
@@ -1 +1 @@
-Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
+Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work.
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..6028bd109b 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,37 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php
deleted file mode 100644
index 4eaf108f15..0000000000
--- a/src/Appwrite/Event/Build.php
+++ /dev/null
@@ -1,146 +0,0 @@
-setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME))
- ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME));
- }
-
- /**
- * Sets template for the build event.
- *
- * @param Document $template
- * @return self
- */
- public function setTemplate(Document $template): self
- {
- $this->template = $template;
-
- return $this;
- }
-
- /**
- * Sets resource document for the build event.
- *
- * @param Document $resource
- * @return self
- */
- public function setResource(Document $resource): self
- {
- $this->resource = $resource;
-
- return $this;
- }
-
- /**
- * Returns set resource document for the build event.
- *
- * @return null|Document
- */
- public function getResource(): ?Document
- {
- return $this->resource;
- }
-
- /**
- * Sets deployment for the build event.
- *
- * @param Document $deployment
- * @return self
- */
- public function setDeployment(Document $deployment): self
- {
- $this->deployment = $deployment;
-
- return $this;
- }
-
- /**
- * Returns set deployment for the build event.
- *
- * @return null|Document
- */
- public function getDeployment(): ?Document
- {
- return $this->deployment;
- }
-
- /**
- * Sets type for the build event.
- *
- * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`.
- * @return self
- */
- public function setType(string $type): self
- {
- $this->type = $type;
-
- return $this;
- }
-
- /**
- * Returns set type for the function event.
- *
- * @return string
- */
- public function getType(): string
- {
- return $this->type;
- }
-
- /**
- * Prepare payload for queue.
- *
- * @return array
- */
- protected function preparePayload(): array
- {
- $platform = $this->platform;
- if (empty($platform)) {
- $platform = Config::getParam('platform', []);
- }
-
- return [
- 'project' => $this->project,
- 'resource' => $this->resource,
- 'deployment' => $this->deployment,
- 'type' => $this->type,
- 'template' => $this->template,
- 'platform' => $platform,
- ];
- }
-
- /**
- * Resets event.
- *
- * @return self
- */
- public function reset(): self
- {
- $this->type = '';
- $this->resource = null;
- $this->deployment = null;
- $this->template = null;
- $this->platform = [];
- parent::reset();
-
- return $this;
- }
-}
diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php
new file mode 100644
index 0000000000..0c8967aff6
--- /dev/null
+++ b/src/Appwrite/Event/Message/Build.php
@@ -0,0 +1,45 @@
+platform) ? $this->platform : Config::getParam('platform', []);
+
+ return [
+ 'project' => $this->project->getArrayCopy(),
+ 'resource' => $this->resource->getArrayCopy(),
+ 'deployment' => $this->deployment->getArrayCopy(),
+ 'type' => $this->type,
+ 'template' => $this->template?->getArrayCopy(),
+ 'platform' => $platform,
+ ];
+ }
+
+ public static function fromArray(array $data): static
+ {
+ return new self(
+ project: new Document($data['project'] ?? []),
+ resource: new Document($data['resource'] ?? []),
+ deployment: new Document($data['deployment'] ?? []),
+ type: $data['type'] ?? '',
+ template: !empty($data['template']) ? new Document($data['template']) : null,
+ platform: $data['platform'] ?? [],
+ );
+ }
+}
diff --git a/src/Appwrite/Event/Publisher/Build.php b/src/Appwrite/Event/Publisher/Build.php
new file mode 100644
index 0000000000..9b2a3b68a0
--- /dev/null
+++ b/src/Appwrite/Event/Publisher/Build.php
@@ -0,0 +1,27 @@
+publish($queue ?? $this->queue, $message);
+ }
+
+ public function getSize(bool $failed = false, ?Queue $queue = null): int
+ {
+ return $this->getQueueSize($queue ?? $this->queue, $failed);
+ }
+}
diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php
index 2cf8e79944..42dcacaf34 100644
--- a/src/Appwrite/Extend/Exception.php
+++ b/src/Appwrite/Extend/Exception.php
@@ -348,6 +348,10 @@ class Exception extends \Exception
public const string MIGRATION_IN_PROGRESS = 'migration_in_progress';
public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error';
public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported';
+ public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required';
+ public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found';
+ public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid';
+ public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid';
/** Realtime */
public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid';
diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php
index 85dfec3cfd..b0efac3829 100644
--- a/src/Appwrite/Platform/Modules/Compute/Base.php
+++ b/src/Appwrite/Platform/Modules/Compute/Base.php
@@ -2,7 +2,8 @@
namespace Appwrite\Platform\Modules\Compute;
-use Appwrite\Event\Build;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Platform\Action;
@@ -57,7 +58,7 @@ class Base extends Action
return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT;
}
- public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$entrypoint = $function->getAttribute('entrypoint', '');
@@ -150,16 +151,19 @@ class Base extends Action
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
- public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
+ public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document
{
$deploymentId = ID::unique();
$providerInstallationId = $installation->getAttribute('providerInstallationId', '');
@@ -358,11 +362,14 @@ class Base extends Action
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
return $deployment;
}
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
index 757edc0484..57c465faef 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -87,9 +88,10 @@ class Create extends Action
->inject('project')
->inject('deviceForFunctions')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -106,9 +108,10 @@ class Create extends Action
Document $project,
Device $deviceForFunctions,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$activate = \strval($activate) === 'true' || \strval($activate) === '1';
@@ -272,10 +275,13 @@ class Create extends Action
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
index 9884b12dba..76070c8bf5 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -61,8 +62,10 @@ class Create extends Action
->inject('response')
->inject('dbForProject')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForFunctions')
+ ->inject('project')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -73,8 +76,10 @@ class Create extends Action
Response $response,
Database $dbForProject,
Event $queueForEvents,
- Build $queueForBuilds,
- Device $deviceForFunctions
+ BuildPublisher $publisherForBuilds,
+ Device $deviceForFunctions,
+ Document $project,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +132,13 @@ class Create extends Action
'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
index 53af82e701..f18543c60e 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -76,9 +77,10 @@ class Create extends Base
->inject('dbForPlatform')
->inject('queueForEvents')
->inject('project')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -96,9 +98,10 @@ class Create extends Base
Database $dbForPlatform,
Event $queueForEvents,
Document $project,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -127,10 +130,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
referenceType: $type,
reference: $reference
);
@@ -184,11 +188,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('functionId', $function->getId())
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
index 587c09beba..a74fc12593 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -70,8 +70,9 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -86,8 +87,9 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
+ array $platform,
) {
$function = $dbForProject->getDocument('functions', $functionId);
@@ -105,10 +107,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
+ platform: $platform,
reference: $reference,
referenceType: $type
);
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
index 7b294f3f90..00a91141fb 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php
@@ -2,9 +2,10 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
use Appwrite\Event\Func;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Realtime;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Event\Webhook;
@@ -115,7 +116,7 @@ class Create extends Base
->inject('timelimit')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForRealtime')
->inject('queueForWebhooks')
->inject('queueForFunctions')
@@ -157,7 +158,7 @@ class Create extends Base
callable $timelimit,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Realtime $queueForRealtime,
Webhook $queueForWebhooks,
Func $queueForFunctions,
@@ -326,10 +327,11 @@ class Create extends Base
project: $project,
installation: $installation,
dbForProject: $dbForProject,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: true,
+ platform: $platform,
reference: $providerBranch,
referenceType: 'branch'
);
@@ -367,11 +369,14 @@ class Create extends Base
'latestDeploymentStatus' => $deployment->getAttribute('status', ''),
]));
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($function)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $function,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
}
$functionsDomain = $platform['functionsDomain'];
diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
index 7d6572d336..b3fcb2c021 100644
--- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
+++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Functions\Http\Functions;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Validator\FunctionEvent;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
@@ -105,11 +105,12 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
->inject('authorization')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -139,11 +140,12 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
Executor $executor,
- Authorization $authorization
+ Authorization $authorization,
+ array $platform
) {
// TODO: If only branch changes, re-deploy
$function = $dbForProject->getDocument('functions', $functionId);
@@ -281,7 +283,7 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
// Inform scheduler if function is still active
diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
index 7d1cdc4980..c766f73929 100644
--- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
+++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php
@@ -109,9 +109,7 @@ class Screenshots extends Action
throw new \Exception("Rule for deployment not found");
}
- $client = new FetchClient();
- $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000);
- $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+ $timeout = \intval($site->getAttribute('timeout', '15')) * 1000;
$bucket = $dbForPlatform->getDocument('buckets', 'screenshots');
@@ -162,8 +160,8 @@ class Screenshots extends Action
]);
$screenshotError = null;
- $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) {
- return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) {
+ $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) {
+ return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) {
try {
$config = $configs[$key];
@@ -179,6 +177,10 @@ class Screenshots extends Action
}
$browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1');
+ $client = new FetchClient();
+ $client->setTimeout($timeout);
+ $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON);
+
$fetchResponse = $client->fetch(
url: $browserEndpoint . '/screenshots',
method: 'POST',
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
index 8ae7c8687a..98e65e37e5 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -42,16 +42,16 @@ class Get extends Base
contentType: ContentType::JSON
))
->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true)
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('response')
->callback($this->action(...));
}
- public function action(int|string $threshold, Build $queueForBuilds, Response $response): void
+ public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void
{
$threshold = (int) $threshold;
- $size = $queueForBuilds->getSize();
+ $size = $publisherForBuilds->getSize();
$this->assertQueueThreshold($size, $threshold);
diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
index 7602de45d3..0d0a787b46 100644
--- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
+++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php
@@ -2,7 +2,6 @@
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed;
-use Appwrite\Event\Build;
use Appwrite\Event\Database;
use Appwrite\Event\Delete;
use Appwrite\Event\Event;
@@ -10,6 +9,7 @@ use Appwrite\Event\Func;
use Appwrite\Event\Mail;
use Appwrite\Event\Messaging;
use Appwrite\Event\Publisher\Audit;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Event\Publisher\Certificate;
use Appwrite\Event\Publisher\Migration as MigrationPublisher;
use Appwrite\Event\Publisher\Screenshot;
@@ -83,7 +83,7 @@ class Get extends Base
->inject('publisherForUsage')
->inject('queueForWebhooks')
->inject('publisherForCertificates')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('queueForMessaging')
->inject('publisherForMigrations')
->inject('publisherForScreenshots')
@@ -103,7 +103,7 @@ class Get extends Base
UsagePublisher $publisherForUsage,
Webhook $queueForWebhooks,
Certificate $publisherForCertificates,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Messaging $queueForMessaging,
MigrationPublisher $publisherForMigrations,
Screenshot $publisherForScreenshots,
@@ -120,7 +120,7 @@ class Get extends Base
System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage,
System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks,
System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates,
- System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds,
+ System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds,
System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots,
System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging,
System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations,
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
index 006ab3ae90..fa700877a1 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php
@@ -13,6 +13,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
@@ -57,6 +58,7 @@ class Create extends Action
->param('endpoint', '', new URL(), 'Source Appwrite endpoint')
->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject'])
->param('apiKey', '', new Text(512), 'Source API Key')
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('project')
@@ -71,6 +73,7 @@ class Create extends Action
string $endpoint,
string $projectId,
string $apiKey,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Document $project,
@@ -93,6 +96,9 @@ class Create extends Action
'statusCounters' => '{}',
'resourceData' => '{}',
'errors' => [],
+ 'options' => [
+ 'onDuplicate' => $onDuplicate,
+ ],
]));
$queueForEvents->setParam('migrationId', $migration->getId());
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
index 5cc21241c3..4b47ed7d58 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php
@@ -20,6 +20,7 @@ use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\CSV;
@@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
+use Utopia\Validator\WhiteList;
class Create extends Action
{
@@ -67,6 +69,7 @@ class Create extends Action
->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject'])
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
@@ -85,6 +88,7 @@ class Create extends Action
string $fileId,
string $resourceId,
bool $internalFile,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
@@ -183,6 +187,7 @@ class Create extends Action
'options' => [
'path' => $newPath,
'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
],
]));
diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
index 55081b2645..c5d936711e 100644
--- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
+++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php
@@ -20,6 +20,7 @@ use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Resource;
use Utopia\Migration\Sources\Appwrite as AppwriteSource;
use Utopia\Migration\Sources\JSON as JSONSource;
@@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP;
use Utopia\Storage\Device;
use Utopia\System\System;
use Utopia\Validator\Boolean;
+use Utopia\Validator\WhiteList;
class Create extends Action
{
@@ -66,6 +68,7 @@ class Create extends Action
->param('fileId', '', new UID(), 'File ID.')
->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.')
->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true)
+ ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true)
->inject('response')
->inject('dbForProject')
->inject('dbForPlatform')
@@ -84,6 +87,7 @@ class Create extends Action
string $fileId,
string $resourceId,
bool $internalFile,
+ string $onDuplicate,
Response $response,
Database $dbForProject,
Database $dbForPlatform,
@@ -183,6 +187,7 @@ class Create extends Action
'options' => [
'path' => $newPath,
'size' => $fileSize,
+ 'onDuplicate' => $onDuplicate,
],
]));
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
index 71ea5ceb2f..63ed776709 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
@@ -85,7 +86,7 @@ class Create extends Action
->inject('queueForEvents')
->inject('deviceForSites')
->inject('deviceForLocal')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('plan')
->inject('authorization')
->inject('platform')
@@ -107,7 +108,7 @@ class Create extends Action
Event $queueForEvents,
Device $deviceForSites,
Device $deviceForLocal,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $plan,
Authorization $authorization,
array $platform,
@@ -315,10 +316,13 @@ class Create extends Action
}
// Start the build
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
} else {
if ($deployment->isEmpty()) {
$deployment = $dbForProject->createDocument('deployments', new Document([
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
index 546549604b..b3619c6017 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
@@ -63,7 +64,7 @@ class Create extends Action
->inject('dbForProject')
->inject('dbForPlatform')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('deviceForSites')
->inject('authorization')
->inject('platform')
@@ -79,7 +80,7 @@ class Create extends Action
Database $dbForProject,
Database $dbForPlatform,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Device $deviceForSites,
Authorization $authorization,
array $platform
@@ -177,10 +178,13 @@ class Create extends Action
]))
);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
index f648c57a83..29854d473b 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -77,7 +78,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -98,7 +99,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -130,7 +131,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
@@ -223,11 +224,14 @@ class Create extends Base
$this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($site)
- ->setDeployment($deployment)
- ->setTemplate($template);
+ $publisherForBuilds->enqueue(new BuildMessage(
+ project: $project,
+ resource: $site,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ template: $template,
+ platform: $platform,
+ ));
$queueForEvents
->setParam('siteId', $site->getId())
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
index 4351dd8dd9..d34b8c4055 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
@@ -71,7 +71,7 @@ class Create extends Base
->inject('dbForPlatform')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('gitHub')
->inject('authorization')
->inject('platform')
@@ -89,7 +89,7 @@ class Create extends Base
Database $dbForPlatform,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
GitHub $github,
Authorization $authorization,
array $platform
@@ -111,7 +111,7 @@ class Create extends Base
installation: $installation,
dbForProject: $dbForProject,
dbForPlatform: $dbForPlatform,
- queueForBuilds: $queueForBuilds,
+ publisherForBuilds: $publisherForBuilds,
template: $template,
github: $github,
activate: $activate,
diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
index 3c0d090b7b..2aee03265e 100644
--- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
+++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php
@@ -2,8 +2,8 @@
namespace Appwrite\Platform\Modules\Sites\Http\Sites;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\Platform\Modules\Compute\Validator\Specification;
@@ -99,10 +99,11 @@ class Update extends Base
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('dbForPlatform')
->inject('gitHub')
->inject('executor')
+ ->inject('platform')
->callback($this->action(...));
}
@@ -133,10 +134,11 @@ class Update extends Base
Database $dbForProject,
Document $project,
Event $queueForEvents,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
Database $dbForPlatform,
GitHub $github,
- Executor $executor
+ Executor $executor,
+ array $platform
) {
if (!empty($adapter)) {
$configFramework = Config::getParam('frameworks')[$framework] ?? [];
@@ -279,7 +281,7 @@ class Update extends Base
// Redeploy logic
if (!$isConnected && !empty($providerRepositoryId)) {
- $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true);
+ $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform);
}
$queueForEvents->setParam('siteId', $site->getId());
diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
new file mode 100644
index 0000000000..ef2ace34ff
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php
@@ -0,0 +1,20 @@
+inject('project')
->inject('authorization')
->inject('user')
+ ->inject('cacheControlForStorage')
->callback($this->action(...));
}
@@ -120,7 +123,8 @@ class Get extends Action
Device $deviceForLocal,
Document $project,
Authorization $authorization,
- User $user
+ User $user,
+ callable $cacheControlForStorage
) {
if (!\extension_loaded('imagick')) {
@@ -241,28 +245,43 @@ class Get extends Action
throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage());
}
- $image->crop((int) $width, (int) $height, $gravity);
+ if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) {
+ Span::add('storage.transform.crop.width', $width);
+ Span::add('storage.transform.crop.height', $height);
+ Span::add('storage.transform.crop.gravity', $gravity);
+ $image->crop($width, $height, $gravity);
+ }
- if (!empty($opacity)) {
+ if ($opacity !== 1.0) {
+ Span::add('storage.transform.opacity', $opacity);
$image->setOpacity($opacity);
}
if (!empty($background)) {
+ Span::add('storage.transform.background', $background);
$image->setBackground('#' . $background);
}
- if (!empty($borderWidth)) {
+ if ($borderWidth > 0) {
+ Span::add('storage.transform.border.width', $borderWidth);
+ Span::add('storage.transform.border.color', $borderColor);
$image->setBorder($borderWidth, '#' . $borderColor);
}
- if (!empty($borderRadius)) {
+ if ($borderRadius > 0) {
+ Span::add('storage.transform.borderRadius', $borderRadius);
$image->setBorderRadius($borderRadius);
}
- if (!empty($rotation)) {
+ if ($rotation !== 0) {
+ Span::add('storage.transform.rotation', $rotation);
$image->setRotation(($rotation + 360) % 360);
}
+ if ($quality !== -1) {
+ Span::add('storage.transform.quality', $quality);
+ }
+
$data = $image->output($output, $quality);
$renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime;
@@ -294,8 +313,20 @@ class Get extends Action
}
}
+ $maxAge = 2592000; // 30 days
+ $cacheControl = $cacheControlForStorage(new StorageCacheControl(
+ source: CacheControl::SOURCE_ACTION,
+ user: $user,
+ maxAge: $maxAge,
+ project: $project,
+ bucket: $bucket,
+ file: $file,
+ resourceToken: $resourceToken,
+ fileSecurity: $fileSecurity,
+ ));
+
$response
- ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days
+ ->addHeader('Cache-Control', $cacheControl)
->setContentType($contentType)
->file($data);
diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
index e174029031..51115b7861 100644
--- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
+++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php
@@ -189,15 +189,15 @@ class Create extends Action
} catch (\Throwable) {
}
- if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
- if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
+ if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) {
throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL);
}
- if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
+ if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) {
throw new Exception(Exception::USER_EMAIL_FREE);
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
index 8b320535e9..a40d7fc6b9 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -60,7 +60,7 @@ class Update extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -75,7 +75,7 @@ class Update extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$installation = $dbForPlatform->getDocument('installations', $installationId);
@@ -130,7 +130,7 @@ class Update extends Action
$providerCommitAuthor = $commitDetails["commitAuthor"] ?? '';
$providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? '';
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
$response->noContent();
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
index 33d7e984fb..8bc090bb03 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php
@@ -2,8 +2,9 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub;
-use Appwrite\Event\Build;
use Appwrite\Event\Event;
+use Appwrite\Event\Message\Build as BuildMessage;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Filter\BranchDomain as BranchDomainFilter;
use Appwrite\Vcs\Comment;
@@ -43,7 +44,7 @@ trait Deployment
bool $external,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -528,14 +529,16 @@ trait Deployment
$queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization);
- $queueForBuilds
- ->setQueue($queueName)
- ->setType(BUILD_TYPE_DEPLOYMENT)
- ->setResource($resource)
- ->setDeployment($deployment)
- ->setProject($project); // set the project because it won't be set for git deployments
-
- $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site
+ $publisherForBuilds->enqueue(
+ new BuildMessage(
+ project: $project,
+ resource: $resource,
+ deployment: $deployment,
+ type: BUILD_TYPE_DEPLOYMENT,
+ platform: $platform,
+ ),
+ new \Utopia\Queue\Queue($queueName)
+ );
Span::add("{$logBase}.build.triggered", 'true');
//TODO: Add event?
@@ -545,8 +548,6 @@ trait Deployment
}
}
- $queueForBuilds->reset(); // prevent shutdown hook from triggering again
-
if (!empty($errors)) {
throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors));
}
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
index e3dbcfa0e9..0b81504309 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php
@@ -2,7 +2,7 @@
namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events;
-use Appwrite\Event\Build;
+use Appwrite\Event\Publisher\Build as BuildPublisher;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment;
@@ -41,7 +41,7 @@ class Create extends Action
->inject('dbForPlatform')
->inject('authorization')
->inject('getProjectDB')
- ->inject('queueForBuilds')
+ ->inject('publisherForBuilds')
->inject('platform')
->callback($this->action(...));
}
@@ -53,7 +53,7 @@ class Create extends Action
Database $dbForPlatform,
Authorization $authorization,
callable $getProjectDB,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
array $platform
) {
$this->preprocessEvent($request);
@@ -78,8 +78,8 @@ class Create extends Action
match ($event) {
$github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization),
- $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
- $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
+ $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform),
default => null,
};
@@ -129,7 +129,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -164,7 +164,7 @@ class Create extends Action
// Create new deployment only on push (not committed by us) and not when branch is deleted
if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) {
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
}
}
@@ -175,7 +175,7 @@ class Create extends Action
GitHub $github,
Database $dbForPlatform,
Authorization $authorization,
- Build $queueForBuilds,
+ BuildPublisher $publisherForBuilds,
callable $getProjectDB,
array $platform,
) {
@@ -216,7 +216,7 @@ class Create extends Action
Query::orderDesc('$createdAt')
]));
- $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform);
+ $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform);
} elseif ($action == "closed") {
// Allowed external contributions cleanup
diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
index 8ead94b7cb..fda462159f 100644
--- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
+++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php
@@ -7,9 +7,12 @@ use Appwrite\Platform\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
+use Appwrite\Utopia\Database\Validator\Queries\Branches;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
+use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Query;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Text;
@@ -49,6 +52,8 @@ class XList extends Action
))
->param('installationId', '', new Text(256), 'Installation Id')
->param('providerRepositoryId', '', new Text(256), 'Repository Id')
+ ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true)
+ ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true)
->inject('gitHub')
->inject('response')
->inject('dbForPlatform')
@@ -58,10 +63,18 @@ class XList extends Action
public function action(
string $installationId,
string $providerRepositoryId,
+ string $search,
+ array $queries,
GitHub $github,
Response $response,
Database $dbForPlatform
) {
+ try {
+ $queries = Query::parseQueries($queries);
+ } catch (QueryException $e) {
+ throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ }
+
$installation = $dbForPlatform->getDocument('installations', $installationId);
if ($installation->isEmpty()) {
@@ -85,11 +98,48 @@ class XList extends Action
$branches = $github->listBranches($owner, $repositoryName);
+ if (!empty($search)) {
+ $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false));
+ }
+
+ $total = \count($branches);
+ [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ ] = Query::groupByType($queries);
+ $cursorQuery = \current(Query::getCursorQueries($queries, false));
+
+ $limit ??= APP_LIMIT_LIST_DEFAULT;
+ $offset ??= 0;
+
+ if ($cursorQuery instanceof Query) {
+ $cursor = $cursorQuery->getValue();
+ $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER
+ ? Database::CURSOR_AFTER
+ : Database::CURSOR_BEFORE;
+
+ $cursorIndex = \array_search($cursor, $branches, true);
+ if ($cursorIndex === false) {
+ throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found.");
+ }
+
+ $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0;
+
+ if ($cursorDirection === Database::CURSOR_BEFORE) {
+ $start = \max(0, $cursorIndex - $limit);
+ $branches = \array_slice($branches, $start, $cursorIndex - $start);
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+ } else {
+ $branches = \array_slice($branches, $offset, $limit);
+ }
+
$response->dynamic(new Document([
'branches' => \array_map(function ($branch) {
return new Document(['name' => $branch]);
}, $branches),
- 'total' => \count($branches),
+ 'total' => $total,
]), Response::MODEL_BRANCH_LIST);
}
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index b1580f0e68..fbf965bd00 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
case 'claude-plugin':
$config = new ClaudePlugin();
break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php
index 3fd86baea9..b6c295b3bb 100644
--- a/src/Appwrite/Platform/Workers/Migrations.php
+++ b/src/Appwrite/Platform/Workers/Migrations.php
@@ -30,6 +30,7 @@ use Utopia\Migration\Destination;
use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite;
use Utopia\Migration\Destinations\CSV as DestinationCSV;
use Utopia\Migration\Destinations\JSON as DestinationJSON;
+use Utopia\Migration\Destinations\OnDuplicate;
use Utopia\Migration\Exception as MigrationException;
use Utopia\Migration\Resource;
use Utopia\Migration\Resources\Database\Database as ResourceDatabase;
@@ -196,13 +197,13 @@ class Migrations extends Action
$projectDB = null;
$useAppwriteApiSource = false;
if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) {
- throw new \Exception('Source projectId is required for Appwrite migrations');
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED);
}
if (! empty($credentials['projectId'])) {
$this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']);
if ($this->sourceProject->isEmpty()) {
- throw new \Exception('Source project not found for provided projectId');
+ throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND);
}
$sourceRegion = $this->sourceProject->getAttribute('region', 'default');
@@ -265,7 +266,7 @@ class Migrations extends Action
$this->deviceForMigrations,
$this->dbForProject,
),
- default => throw new \Exception('Invalid source type'),
+ default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID),
};
$resources = $migration->getAttribute('resources', []);
@@ -291,6 +292,7 @@ class Migrations extends Action
$this->dbForProject,
$this->getDatabasesDB,
Config::getParam('collections', [])['databases']['collections'],
+ OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail,
),
DestinationCSV::getName() => new DestinationCSV(
$this->deviceForFiles,
@@ -310,7 +312,7 @@ class Migrations extends Action
$options['filename'],
$options['columns'] ?? [],
),
- default => throw new \Exception('Invalid destination type'),
+ default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID),
};
}
@@ -436,6 +438,7 @@ class Migrations extends Action
$transfer = $source = $destination = null;
$aggregatedResources = [];
+ $caughtError = null;
$host = System::getEnv('_APP_MIGRATION_HOST');
if (empty($host)) {
@@ -529,7 +532,6 @@ class Migrations extends Action
if (!empty($sourceErrors) || ! empty($destinationErrors)) {
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors));
return;
}
@@ -544,35 +546,69 @@ class Migrations extends Action
$migration->setAttribute('status', 'failed');
$migration->setAttribute('stage', 'finished');
- call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- ]);
+ $caughtError = $th;
+ // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its
+ // registry-driven isPublishable() flag; library-thrown Migration\Exception is
+ // always user-facing; anything else is unknown and surfaced to Sentry.
+ if ($th instanceof Exception) {
+ $publish = $th->isPublishable();
+ } elseif ($th instanceof MigrationException) {
+ $publish = false;
+ } else {
+ $publish = true;
+ }
+
+ if ($publish) {
+ call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
+ 'migrationId' => $migration->getId(),
+ 'source' => $migration->getAttribute('source') ?? '',
+ 'destination' => $migration->getAttribute('destination') ?? '',
+ ]);
+ }
} finally {
try {
+ $sourceErrors = $source?->getErrors() ?? [];
+ $destinationErrors = $destination?->getErrors() ?? [];
+
+ if ($caughtError !== null) {
+ if ($caughtError instanceof MigrationException) {
+ // library-thrown, message constructed by us
+ $bubbled = $caughtError;
+ } elseif ($caughtError instanceof Exception) {
+ // typed AppwriteException — message comes from the curated registry
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: $caughtError->getMessage(),
+ code: $caughtError->getCode(),
+ previous: $caughtError,
+ );
+ } else {
+ // unknown throwable — raw message may embed internal hostnames,
+ // DSNs, tokens, etc. Replace with a generic user-facing string;
+ // the original is preserved on `previous:` for Sentry.
+ $bubbled = new MigrationException(
+ resourceName: '',
+ resourceGroup: '',
+ message: 'Migration failed due to an unexpected error.',
+ code: $caughtError->getCode() ?: 500,
+ previous: $caughtError,
+ );
+ }
+ $destinationErrors[] = $bubbled;
+ }
+
+ $migration->setAttribute('errors', $this->sanitizeErrors(
+ $sourceErrors,
+ $destinationErrors,
+ ));
+
$this->updateMigrationDocument($migration, $project, $queueForRealtime);
if ($migration->getAttribute('status', '') === 'failed') {
Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')');
- $sourceErrors = $source?->getErrors() ?? [];
- $destinationErrors = $destination?->getErrors() ?? [];
-
- foreach ([...$sourceErrors, ...$destinationErrors] as $error) {
- /** @var MigrationException $error */
- if ($error->getCode() === 0 || $error->getCode() >= 500) {
- ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [
- 'migrationId' => $migration->getId(),
- 'source' => $migration->getAttribute('source') ?? '',
- 'destination' => $migration->getAttribute('destination') ?? '',
- 'resourceName' => $error->getResourceName(),
- 'resourceGroup' => $error->getResourceGroup(),
- ]);
- }
- }
-
$source?->error();
$destination?->error();
}
diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php
index 30df5acf52..4c7fa6f377 100644
--- a/src/Appwrite/SDK/Specification/Format.php
+++ b/src/Appwrite/SDK/Specification/Format.php
@@ -68,6 +68,17 @@ abstract class Format
'mock-unverified'
],
],
+ [
+ 'namespace' => 'project',
+ 'methods' => [
+ 'getOAuth2Provider'
+ ],
+ 'parameter' => 'providerId',
+ 'excludeKeys' => [
+ 'mock',
+ 'mock-unverified'
+ ],
+ ],
];
/**
@@ -743,6 +754,24 @@ abstract class Format
break;
case 'project':
switch ($method) {
+ case 'updateAuthMethod':
+ switch ($param) {
+ case 'methodId':
+ return 'AuthMethod';
+ }
+ break;
+ case 'getPolicy':
+ switch ($param) {
+ case 'policyId':
+ return 'ProjectPolicy';
+ }
+ break;
+ case 'getOAuth2Provider':
+ switch ($param) {
+ case 'providerId':
+ return 'OAuthProvider';
+ }
+ break;
case 'getEmailTemplate':
case 'updateEmailTemplate':
switch ($param) {
diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
index 962bc8948a..f69ec3972a 100644
--- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
+++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php
@@ -524,6 +524,7 @@ class OpenAPI3 extends Format
case \Appwrite\Utopia\Database\Validator\Queries\Identities::class:
case \Appwrite\Utopia\Database\Validator\Queries\Indexes::class:
case \Appwrite\Utopia\Database\Validator\Queries\Installations::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Memberships::class:
case \Appwrite\Utopia\Database\Validator\Queries\Messages::class:
case \Appwrite\Utopia\Database\Validator\Queries\Migrations::class:
@@ -755,7 +756,18 @@ class OpenAPI3 extends Format
$node['schema']['default'] = $param['default'];
}
- if (false !== \strpos($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -796,7 +808,14 @@ class OpenAPI3 extends Format
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php
index d07d957577..52e33bcc27 100644
--- a/src/Appwrite/SDK/Specification/Format/Swagger2.php
+++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php
@@ -511,6 +511,7 @@ class Swagger2 extends Format
case \Utopia\Database\Validator\Queries::class:
case \Utopia\Database\Validator\Queries\Document::class:
case \Utopia\Database\Validator\Queries\Documents::class:
+ case \Appwrite\Utopia\Database\Validator\Queries\Branches::class:
case \Appwrite\Utopia\Database\Validator\Queries\Columns::class:
case \Appwrite\Utopia\Database\Validator\Queries\Tables::class:
$node['type'] = 'array';
@@ -722,7 +723,18 @@ class Swagger2 extends Format
$node['default'] = $param['default'];
}
- if (\str_contains($url, ':' . $name)) { // Param is in URL path
+ $pathAliases = [$name, ...($param['aliases'] ?? [])];
+ $pathAliasMap = \array_flip($pathAliases);
+ $isPathParam = false;
+
+ foreach (\explode('/', $url) as $segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $isPathParam = true;
+ break;
+ }
+ }
+
+ if ($isPathParam) { // Param is in URL path (directly or through alias)
$node['in'] = 'path';
$temp['parameters'][] = $node;
} elseif ($route->getMethod() == 'GET') { // Param is in query
@@ -767,7 +779,14 @@ class Swagger2 extends Format
}
}
- $url = \str_replace(':' . $name, '{' . $name . '}', $url);
+ $segments = \explode('/', $url);
+ foreach ($segments as &$segment) {
+ if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) {
+ $segment = '{' . $name . '}';
+ }
+ }
+ unset($segment);
+ $url = \implode('/', $segments);
}
if (!empty($bodyRequired)) {
diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
new file mode 100644
index 0000000000..82ca911747
--- /dev/null
+++ b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php
@@ -0,0 +1,20 @@
+getMethod();
+
+ if (!\in_array($method, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) {
+ $this->message = 'Invalid query method: ' . $method;
+ return false;
+ }
+
+ $cursor = $value->getValue();
+
+ $validator = new Text(256);
+ if (!$validator->isValid($cursor)) {
+ $this->message = 'Invalid cursor: ' . $validator->getDescription();
+ return false;
+ }
+
+ return true;
+ }
+
+ public function getMethodType(): string
+ {
+ return self::METHOD_TYPE_CURSOR;
+ }
+}
diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 4dc0174e50..8741ecff6c 100644
--- a/src/Appwrite/Vcs/Comment.php
+++ b/src/Appwrite/Vcs/Comment.php
@@ -50,6 +50,8 @@ class Comment
protected string $statePrefix = '[appwrite]: #';
+ protected ?string $tip = null;
+
/**
* @var mixed[] $builds
*/
@@ -81,7 +83,14 @@ class Comment
public function generateComment(): string
{
- $json = \json_encode($this->builds);
+ if ($this->tip === null) {
+ $this->tip = $this->tips[\array_rand($this->tips)];
+ }
+
+ $json = \json_encode([
+ 'builds' => $this->builds,
+ 'tip' => $this->tip,
+ ]);
$text = $this->statePrefix . \base64_encode($json) . "\n\n";
@@ -226,8 +235,7 @@ class Comment
$i++;
}
- $tip = $this->tips[array_rand($this->tips)];
- $text .= "\n
\n\n> [!TIP]\n> $tip\n\n";
+ $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n";
return $text;
}
@@ -252,8 +260,15 @@ class Comment
$json = \base64_decode($state);
- $builds = \json_decode($json, true);
- $this->builds = \is_array($builds) ? $builds : [];
+ $data = \json_decode($json, true);
+
+ if (\is_array($data) && \array_key_exists('builds', $data)) {
+ $this->builds = \is_array($data['builds']) ? $data['builds'] : [];
+ $this->tip = $data['tip'] ?? null;
+ } else {
+ // Backward compatibility with old state format (builds array only)
+ $this->builds = \is_array($data) ? $data : [];
+ }
return $this;
}
diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php
index c42679018e..9010490cc9 100644
--- a/tests/e2e/Services/GraphQL/Base.php
+++ b/tests/e2e/Services/GraphQL/Base.php
@@ -4,6 +4,7 @@ namespace Tests\E2E\Services\GraphQL;
use CURLFile;
use Utopia\Console;
+use Utopia\Image\Image;
trait Base
{
@@ -516,6 +517,21 @@ trait Base
}
';
+ protected function assertFilePreviewResponse(array $file): void
+ {
+ $this->assertEquals(200, $file['headers']['status-code']);
+ $this->assertEquals('image/png', $file['headers']['content-type']);
+ $this->assertNotEmpty($file['body']);
+
+ $image = new Image($file['body']);
+ $dimensions = \getimagesizefromstring($file['body']);
+
+ $this->assertNotEmpty($image->output('png'));
+ $this->assertIsArray($dimensions);
+ $this->assertEquals(100, $dimensions[0]);
+ $this->assertEquals(100, $dimensions[1]);
+ }
+
public function getQuery(string $name): string
{
switch ($name) {
@@ -2388,8 +2404,8 @@ trait Base
}
}';
case self::GET_FILE_PREVIEW:
- return 'query getFilePreview($bucketId: String!, $fileId: String!) {
- storageGetFilePreview(bucketId: $bucketId, fileId: $fileId) {
+ return 'query getFilePreview($bucketId: String!, $fileId: String!, $width: Int, $height: Int) {
+ storageGetFilePreview(bucketId: $bucketId, fileId: $fileId, width: $width, height: $height) {
status
}
}';
diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php
index dd89819c34..9cdf523a0a 100644
--- a/tests/e2e/Services/GraphQL/StorageClientTest.php
+++ b/tests/e2e/Services/GraphQL/StorageClientTest.php
@@ -200,7 +200,7 @@ class StorageClientTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php
index 1377ef9207..7808c50be6 100644
--- a/tests/e2e/Services/GraphQL/StorageServerTest.php
+++ b/tests/e2e/Services/GraphQL/StorageServerTest.php
@@ -262,7 +262,7 @@ class StorageServerTest extends Scope
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $gqlPayload);
- $this->assertEquals(46719, \strlen($file['body']));
+ $this->assertFilePreviewResponse($file);
return $file;
}
diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php
index 4212edb9df..8dd5b2fef6 100644
--- a/tests/e2e/Services/Migrations/MigrationsBase.php
+++ b/tests/e2e/Services/Migrations/MigrationsBase.php
@@ -761,6 +761,1275 @@ trait MigrationsBase
self::$cachedTableData = [];
}
+ /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */
+ public function testAppwriteMigrationRowsOnDuplicate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'Original'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ $rowId = $row['body']['$id'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: destination is empty, strict completion expected.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Mutate destination row to prove onDuplicate=skip preserves it.
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [
+ 'data' => ['name' => 'Mutated'],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals('Mutated', $mutate['body']['name']);
+
+ // Re-migration with onDuplicate=skip — completion is strict because
+ // DestinationAppwrite tolerates existing schema resources.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterSkip['headers']['status-code']);
+ $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row');
+
+ // Re-migration with onDuplicate=overwrite — strict completion; destination
+ // row restored to source value.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']);
+ $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Unchanged source under Skip/Overwrite is a no-op — every resource Tolerated. */
+ public function testAppwriteMigrationReRunIsIdempotent(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ // Seed two rows on source so the row-level tolerance is exercised too.
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'Seeded ' . $rowId],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+ }
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: fresh destination.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Re-run under Skip: nothing on source has changed. Destination
+ // schema + rows are already correct — expect clean completion.
+ $reRunSkip = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $reRunSkip['status']);
+
+ // Re-run under Overwrite: same unchanged source. Schema tolerance path
+ // fires for each resource; rows go through DB-native upsert.
+ $reRunOverwrite = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $reRunOverwrite['status']);
+
+ foreach (['row-a', 'row-b'] as $rowId) {
+ $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $check['headers']['status-code']);
+ $this->assertEquals('Seeded ' . $rowId, $check['body']['name']);
+ }
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-me';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration — dest empty, strict completion.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // `_updatedAt` is stored at second granularity (strtotime) — ensure
+ // the source edits below produce a strictly-newer timestamp than
+ // dest's first-migration timestamp.
+ sleep(1);
+
+ // Mutate source: rename database + toggle table enabled.
+ $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [
+ 'name' => 'Renamed Source DB',
+ ]);
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Renamed Source Table',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Overwrite re-migration: UpdateInPlace path fires for database + table.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Assert dest database metadata reflects source's new values.
+ $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders);
+ $this->assertEquals(200, $destDb['headers']['status-code']);
+ $this->assertEquals('Renamed Source DB', $destDb['body']['name']);
+
+ // Assert dest table metadata reflects source's new values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Renamed Source Table', $destTable['body']['name']);
+ $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false');
+ $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true');
+
+ // Child row untouched — UpdateInPlace only rewrites container metadata.
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest container drift even when source has diverged. */
+ public function testAppwriteMigrationSkipPreservesContainerDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ // First migration: dest gets whatever source had.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Mutate dest: ops tightens permissions and renames the table for
+ // its production-specific branding.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [
+ 'name' => 'Dest-Managed Table',
+ 'permissions' => [Permission::read(Role::users())],
+ 'rowSecurity' => false,
+ 'enabled' => true,
+ ]);
+
+ // Also mutate source so the second run has a real divergence.
+ $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [
+ 'name' => 'Source Renamed',
+ 'permissions' => [Permission::read(Role::any())],
+ 'rowSecurity' => true,
+ 'enabled' => false,
+ ]);
+
+ // Skip re-migration: must tolerate existing destination — no update.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ // Dest kept its tightened values.
+ $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders);
+ $this->assertEquals(200, $destTable['headers']['status-code']);
+ $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift');
+ $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */
+ public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration: dest mirrors source (one column 'name').
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Add an orphan column directly on destination (not on source).
+ // Simulates the post-rename state: source dropped a column, dest
+ // still has it — or a dest-only column added by a separate app.
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'orphan_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ // Seed a row on source so per-table orphan cleanup fires inside
+ // createRecord (before rows land), not just at end of run.
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Overwrite re-migration: orphan_col must be dropped from dest.
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Orphan column dropped.
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders);
+ $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares');
+
+ // Source's column preserved.
+ $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves orphan columns; cleanup is Overwrite-only. */
+ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $orphanResp = $this->client->call(
+ Client::METHOD_POST,
+ '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string',
+ $destHeaders,
+ [
+ 'key' => 'dest_only_col',
+ 'size' => 50,
+ 'required' => false,
+ ]
+ );
+ $this->assertEquals(202, $orphanResp['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 5000, 500);
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => ID::unique(),
+ 'data' => ['name' => 'seed'],
+ ]);
+
+ // Skip re-migration: orphan column must NOT be dropped.
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders);
+ $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */
+ public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'persist-on-inplace';
+
+ // Seed a row that proves drop+recreate didn't happen — recreate would
+ // have wiped this column's data on the destination.
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'SeedRow'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ // First migration — dest gets the column as required:true.
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $beforeUpdate['headers']['status-code']);
+ $this->assertTrue($beforeUpdate['body']['required']);
+
+ // _updatedAt has second granularity; ensure source's PATCH produces a
+ // strictly-newer timestamp than the dest's first-migration value.
+ sleep(1);
+
+ // SDK-reachable change set: required true→false, default null→'unknown'.
+ // Both fields are supported by PATCH /columns/string/:key — must route
+ // through updateAttributeInPlace, not DropAndRecreate.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => false,
+ 'default' => 'unknown',
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ $this->assertEquals('unknown', $r['body']['default']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false');
+ $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default');
+ }, 10000, 500);
+
+ // Pre-existing row preserved — proof that the path was UpdateInPlace
+ // and not DropAndRecreate (which would have nulled this column).
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */
+ public function testAppwriteMigrationSkipPreservesAttributeDrift(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Dest divergence: ops loosens the column for a production-only need.
+ $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [
+ 'required' => false,
+ 'default' => 'dest-default',
+ ]);
+ $this->assertEquals(200, $destPatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertFalse($r['body']['required']);
+ }, 5000, 500);
+
+ sleep(1);
+
+ // Source advances strictly later (and to a different value). Under
+ // Overwrite this would propagate to dest; under Skip it must not.
+ $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [
+ 'required' => true,
+ 'default' => null,
+ ]);
+ $this->assertEquals(200, $sourcePatch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertTrue($r['body']['required']);
+ }, 5000, 500);
+
+ $skipResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEquals('completed', $skipResult['status']);
+
+ $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAttr['headers']['status-code']);
+ $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift');
+ $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */
+ public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Rel In-Place DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Two-way: parents.kids ↔ children.parent. Required to hit the in-place path.
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Both sides land on dest with onDelete=cascade.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete.
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Both sides on dest must reflect onDelete=restrict. Asserting the
+ // partner side is the regression guard for the previously-missed
+ // partner meta refresh in updateRelationshipInPlace.
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType');
+ $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay');
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */
+ public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'Two-Way Recreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ 'permissions' => [
+ Permission::create(Role::any()),
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ // Add a non-relationship column on parents so we can POST a row with
+ // non-empty data. tablesdb POST /rows rejects empty data arrays in
+ // 1.9.x (Create.php:161 — getSupportForEmptyDocument() defaults false).
+ $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [
+ 'key' => 'label',
+ 'size' => 32,
+ 'required' => false,
+ ]);
+ $this->assertEquals(202, $createLabel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [
+ 'rowId' => 'parent-1',
+ 'data' => ['label' => 'p1'],
+ ]);
+ $this->assertEquals(201, $parentRow['headers']['status-code']);
+ $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [
+ 'rowId' => 'child-1',
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(201, $childRow['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ // Recreate the relationship on source so its createdAt advances past
+ // dest's stored value — forces SchemaAction::DropAndRecreate on the
+ // parent side, which is the path the partner-side dedup guards.
+ sleep(1);
+ $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(204, $deleteRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ sleep(1);
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => true,
+ 'key' => 'kids',
+ 'twoWayKey' => 'parent',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Child-row's relationship was wiped by the source-side delete. Re-link.
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [
+ 'data' => ['parent' => 'parent-1'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $parent['headers']['status-code']);
+ $this->assertEquals('available', $parent['body']['status']);
+
+ $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders);
+ $this->assertEquals(200, $child['headers']['status-code']);
+ $this->assertEquals('available', $child['body']['status']);
+ }, 10000, 500);
+
+ // Both rows survive the re-migration. If the partner-side dedup were
+ // missing and the partner pass re-fired DropAndRecreate, the partner
+ // (children) table's row would have been wiped before the row pass.
+ $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders);
+ $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration');
+ $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */
+ public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $databaseId = ID::unique();
+ $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [
+ 'databaseId' => $databaseId,
+ 'name' => 'One-Way DropAndRecreate DB',
+ ]);
+ $this->assertEquals(201, $createDb['headers']['status-code']);
+
+ foreach (['parents', 'children'] as $tbl) {
+ $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [
+ 'tableId' => $tbl,
+ 'name' => $tbl,
+ ]);
+ $this->assertEquals(201, $createTable['headers']['status-code']);
+ }
+
+ $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [
+ 'relatedTableId' => 'children',
+ 'type' => Database::RELATION_ONE_TO_MANY,
+ 'twoWay' => false,
+ 'key' => 'kids',
+ 'onDelete' => Database::RELATION_MUTATE_CASCADE,
+ ]);
+ $this->assertEquals(202, $createRel['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']);
+ }, 10000, 500);
+
+ sleep(1);
+
+ $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [
+ 'onDelete' => Database::RELATION_MUTATE_RESTRICT,
+ ]);
+ $this->assertEquals(200, $patch['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']);
+ }, 5000, 500);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $destHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete');
+ $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType');
+ $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false');
+ }, 10000, 500);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */
+ public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-after-recreate';
+
+ $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+ $this->assertEquals(201, $row['headers']['status-code']);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ sleep(1);
+
+ // Drop + recreate the column on source. createdAt advances → re-migration
+ // must take the createdAt-diff DropAndRecreate path on dest.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ // Recreate with `array: true` — a non-SDK change (`array` is in
+ // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail
+ // and the caller to fall through to drop+recreate, which is what
+ // this test pins.
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => false,
+ 'array' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now).
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => ['after-recreate']],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) {
+ $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $col['headers']['status-code']);
+ $this->assertEquals('available', $col['body']['status']);
+ $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)');
+ $this->assertFalse($col['body']['required']);
+ }, 10000, 500);
+
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value');
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
+ /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */
+ public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void
+ {
+ $sourceHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ];
+ $destHeaders = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getDestinationProject()['$id'],
+ 'x-appwrite-key' => $this->getDestinationProject()['apiKey'],
+ ];
+
+ $data = $this->setupMigrationTable();
+ $databaseId = $data['databaseId'];
+ $tableId = $data['tableId'];
+ $rowId = 'row-spec-match';
+
+ $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [
+ 'rowId' => $rowId,
+ 'data' => ['name' => 'before-recreate'],
+ ]);
+
+ $resources = [
+ Resource::TYPE_DATABASE,
+ Resource::TYPE_TABLE,
+ Resource::TYPE_COLUMN,
+ Resource::TYPE_ROW,
+ ];
+
+ $first = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ ]);
+ $this->assertEquals('completed', $first['status']);
+
+ $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destBefore['headers']['status-code']);
+ $destCreatedAtBefore = $destBefore['body']['$createdAt'];
+
+ sleep(1);
+
+ // Drop + recreate with the EXACT same spec as setupMigrationTable
+ // (size=100, required=true). Source's $createdAt advances but the
+ // spec is identical → spec-match guard must force Tolerate.
+ $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(204, $delete['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(404, $r['headers']['status-code']);
+ }, 10000, 500);
+
+ $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [
+ 'key' => 'name',
+ 'size' => 100,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $recreate['headers']['status-code']);
+
+ $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) {
+ $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders);
+ $this->assertEquals(200, $r['headers']['status-code']);
+ $this->assertEquals('available', $r['body']['status']);
+ }, 10000, 500);
+
+ $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [
+ 'data' => ['name' => 'after-recreate'],
+ ]);
+ $this->assertEquals(200, $relink['headers']['status-code']);
+
+ $overwriteResult = $this->performMigrationSync([
+ 'resources' => $resources,
+ 'endpoint' => $this->webEndpoint,
+ 'projectId' => $this->getProject()['$id'],
+ 'apiKey' => $this->getProject()['apiKey'],
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEquals('completed', $overwriteResult['status']);
+
+ // Spec-match guard fired → dest column's $createdAt stayed at the
+ // first-migration value. If DropAndRecreate had run, $createdAt
+ // would have been bumped to source's NEW createdAt.
+ $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders);
+ $this->assertEquals(200, $destAfter['headers']['status-code']);
+ $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched');
+ $this->assertEquals(100, $destAfter['body']['size']);
+ $this->assertTrue($destAfter['body']['required']);
+
+ // Row pass under Overwrite still propagated source's new row value.
+ $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders);
+ $this->assertEquals(200, $rowAfter['headers']['status-code']);
+ $this->assertEquals('after-recreate', $rowAfter['body']['name']);
+
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders);
+ $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders);
+
+ self::$cachedDatabaseData = [];
+ self::$cachedTableData = [];
+ }
+
/**
* Storage
*/
@@ -1483,6 +2752,260 @@ trait MigrationsBase
}, 10_000, 500);
}
+ /**
+ * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests.
+ * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge].
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareCsvImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.csv fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ // Columns are created async (202). Wait for both to be `available`
+ // before proceeding so the migration worker doesn't race the schema.
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['csv'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.csv (100 rows with $id, name, age columns)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged.
+ */
+ public function testCreateCSVImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=skip: no errors, mutated row preserved
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row kept its mutated value (not overwritten by CSV's original age)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on re-import: existing rows are replaced with imported values.
+ */
+ public function testCreateCSVImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite');
+
+ // First import: 100 rows created
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove overwrite restores it to the CSV's original value
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ // Second import with onDuplicate=overwrite: mutated row restored to CSV value
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Mutated row is back to CSV's original age (proving overwrite actually replaced the row)
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ // Row count still 100 (no duplicates created)
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException.
+ * Regression guard so the skip/overwrite additions don't silently change the default.
+ */
+ public function testCreateCSVImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default');
+
+ // First import: succeeds
+ $first = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ // Second import with no flags: should fail on duplicate ids
+ $second = $this->performCsvMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
private function performCsvMigration(array $body): array
{
return $this->client->call(Client::METHOD_POST, '/migrations/csv', [
@@ -1492,6 +3015,246 @@ trait MigrationsBase
], $body);
}
+ /**
+ * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests.
+ * Mirrors prepareCsvImportFixture but uploads documents.json instead.
+ *
+ * @return array{string,string,string,string,string,string,int}
+ */
+ private function prepareJsonImportFixture(string $testLabel): array
+ {
+ $headers = [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey']
+ ];
+
+ // database
+ $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [
+ 'databaseId' => ID::unique(),
+ 'name' => 'Test JSON DB ' . $testLabel,
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $databaseId = $response['body']['$id'];
+
+ // table
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [
+ 'name' => 'Test JSON table ' . $testLabel,
+ 'tableId' => ID::unique(),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $tableId = $response['body']['$id'];
+
+ // columns: name, age (match documents.json fixture)
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [
+ 'key' => 'name',
+ 'size' => 256,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [
+ 'key' => 'age',
+ 'min' => 18,
+ 'max' => 65,
+ 'required' => true,
+ ]);
+ $this->assertEquals(202, $response['headers']['status-code']);
+
+ foreach (['name', 'age'] as $column) {
+ $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) {
+ $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers);
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertEquals('available', $response['body']['status']);
+ }, 5000, 500);
+ }
+
+ // bucket
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [
+ 'bucketId' => ID::unique(),
+ 'name' => 'JSON Bucket ' . $testLabel,
+ 'maximumFileSize' => 2000000,
+ 'allowedFileExtensions' => ['json'],
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $bucketId = $response['body']['$id'];
+
+ // upload documents.json (same row shape as documents.csv)
+ $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'),
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+ $fileId = $response['body']['$id'];
+
+ // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56
+ return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56];
+ }
+
+ /**
+ * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged.
+ */
+ public function testCreateJSONImportSkipDuplicates(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ // Mutate one row so we can prove skip does NOT overwrite it
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'skip',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values.
+ */
+ public function testCreateJSONImportOverwrite(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']);
+ }, 10_000, 500);
+
+ $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ 'x-appwrite-key' => $this->getProject()['apiKey'],
+ ], [
+ 'data' => ['age' => 22],
+ ]);
+ $this->assertEquals(200, $mutate['headers']['status-code']);
+ $this->assertEquals(22, $mutate['body']['age']);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ 'onDuplicate' => 'overwrite',
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals(200, $row['headers']['status-code']);
+ $this->assertEquals($originalName, $row['body']['name']);
+ $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value');
+
+ $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [Query::limit(150)->toString()],
+ ]);
+ $this->assertEquals(100, $rows['body']['total']);
+ }
+
+ /**
+ * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids.
+ */
+ public function testCreateJSONImportDefaultFailsOnDuplicate(): void
+ {
+ [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default');
+
+ $first = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($first) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('completed', $migration['body']['status']);
+ }, 10_000, 500);
+
+ $second = $this->performJsonMigration([
+ 'fileId' => $fileId,
+ 'bucketId' => $bucketId,
+ 'resourceId' => $databaseId . ':' . $tableId,
+ ]);
+ $this->assertEventually(function () use ($second) {
+ $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+ $this->assertEquals('finished', $migration['body']['stage']);
+ $this->assertEquals('failed', $migration['body']['status']);
+ $this->assertNotEmpty($migration['body']['errors']);
+ }, 60_000, 500);
+ }
+
/**
* Test CSV export with email notification
*/
diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
index 3a9037c368..8b0c1af57f 100644
--- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
+++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php
@@ -787,12 +787,240 @@ class ProjectsConsoleClientTest extends Scope
'projectId' => ID::unique(),
'name' => 'Project Test',
'teamId' => $team['body']['$id'],
- 'region' => System::getEnv('_APP_REGION', 'default')
+ 'region' => System::getEnv('_APP_REGION', 'default'),
+ 'description' => 'My description',
+ 'logo' => 'https://google.com/logo.png',
+ 'url' => 'https://myapp.com/',
+ 'legalName' => 'Legal company',
+ 'legalCountry' => 'Slovakia',
+ 'legalState' => 'Custom state',
+ 'legalCity' => 'Košice',
+ 'legalAddress' => 'Main street 32',
+ 'legalTaxId' => 'TAXID_123456'
]);
$this->assertEquals(201, $response['headers']['status-code']);
$id = $response['body']['$id'];
+ // Increase ping 3x
+ for ($i = 0; $i < 3; $i++) {
+ $response = $this->client->call(
+ Client::METHOD_GET,
+ '/ping',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ ], $this->getHeaders()),
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => true,
+ 'senderName' => 'Custom sender',
+ 'senderEmail' => 'email@custom.com',
+ 'host' => 'maildev',
+ 'port' => 1025,
+ 'replyToEmail' => 'replyto@custom.com',
+ 'replyToName' => 'Reply sender',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Add mock numbers
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+421123456789',
+ 'otp' => '123456'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Add labels
+ $response = $this->client->call(Client::METHOD_PUT, '/project/labels', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'labels' => ['custom1', 'custom2']
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create dev keys
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 1',
+ 'expire' => '2099-05-07 09:23:30.713',
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([
+ 'content-type' => 'application/json',
+ ], $this->getHeaders()), [
+ 'name' => 'Custom key 2',
+ 'expire' => '2099-05-07 11:23:30.713'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'number' => '+420987654321',
+ 'otp' => '654321'
+ ]);
+ $this->assertEquals(201, $response['headers']['status-code']);
+
+ // Setup custom values for project policies
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'duration' => 135
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 54
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 7
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'total' => 9
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => true,
+ 'userEmail' => true,
+ 'userPhone' => true,
+ 'userName' => true,
+ 'userMFA' => true,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => true
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Create webhook
+ $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'webhookId' => 'unique()',
+ 'name' => 'Webhook Test',
+ 'events' => ['users.*.create', 'users.*.update.email'],
+ 'url' => 'https://appwrite.io',
+ 'tls' => true,
+ 'authUsername' => 'username',
+ 'authPassword' => 'password',
+ ]);
+ $this->assertEquals(201, $webhook['headers']['status-code']);
+
+ // Create API key
+ $key = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'keyId' => ID::unique(),
+ 'name' => 'Key Test',
+ 'scopes' => ['teams.read', 'teams.write'],
+ ]);
+ $this->assertEquals(201, $key['headers']['status-code']);
+
+ // Create platform
+ $platform = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'platformId' => ID::unique(),
+ 'type' => 'web',
+ 'name' => 'Web App',
+ 'hostname' => 'localhost',
+ ]);
+ $this->assertEquals(201, $platform['headers']['status-code']);
+
+ // Configure OAuth provider
+ $oauth = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'clientId' => 'github-client-id',
+ 'clientSecret' => 'github-client-secret',
+ 'enabled' => false,
+ ]);
+ $this->assertEquals(200, $oauth['headers']['status-code']);
+
/**
* Test for SUCCESS
*/
@@ -802,10 +1030,452 @@ class ProjectsConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
- $this->assertNotEmpty($response['body']);
$this->assertEquals($id, $response['body']['$id']);
$this->assertEquals('Project Test', $response['body']['name']);
+ $this->assertIsString($response['body']['$createdAt']);
+ $this->assertNotEmpty($response['body']['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$createdAt']));
+
+ $this->assertIsString($response['body']['$updatedAt']);
+ $this->assertNotEmpty($response['body']['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['$updatedAt']));
+
+ $this->assertEquals('My description', $response['body']['description']);
+ $this->assertEquals($team['body']['$id'], $response['body']['teamId']);
+ $this->assertEquals('active', $response['body']['status']);
+ $this->assertEquals('https://google.com/logo.png', $response['body']['logo']);
+ $this->assertEquals('https://myapp.com/', $response['body']['url']);
+ $this->assertEquals('Legal company', $response['body']['legalName']);
+ $this->assertEquals('Slovakia', $response['body']['legalCountry']);
+ $this->assertEquals('Custom state', $response['body']['legalState']);
+ $this->assertEquals('Košice', $response['body']['legalCity']);
+ $this->assertEquals('Main street 32', $response['body']['legalAddress']);
+ $this->assertEquals('TAXID_123456', $response['body']['legalTaxId']);
+ $this->assertEquals(135, $response['body']['authDuration']);
+ $this->assertEquals(54, $response['body']['authLimit']);
+ $this->assertEquals(7, $response['body']['authSessionsLimit']);
+ $this->assertEquals(9, $response['body']['authPasswordHistory']);
+ $this->assertTrue($response['body']['authPasswordDictionary']);
+ $this->assertTrue($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authDisposableEmails']);
+ $this->assertFalse($response['body']['authCanonicalEmails']);
+ $this->assertFalse($response['body']['authFreeEmails']);
+ $this->assertTrue($response['body']['authSessionAlerts']);
+ $this->assertTrue($response['body']['authMembershipsUserName']);
+ $this->assertTrue($response['body']['authMembershipsUserEmail']);
+ $this->assertTrue($response['body']['authMembershipsMfa']);
+ $this->assertTrue($response['body']['authMembershipsUserId']);
+ $this->assertTrue($response['body']['authMembershipsUserPhone']);
+ $this->assertTrue($response['body']['authInvalidateSessions']);
+ $this->assertTrue($response['body']['smtpEnabled']);
+ $this->assertSame('Custom sender', $response['body']['smtpSenderName']);
+ $this->assertSame('email@custom.com', $response['body']['smtpSenderEmail']);
+ $this->assertSame('Reply sender', $response['body']['smtpReplyToName']);
+ $this->assertSame('replyto@custom.com', $response['body']['smtpReplyToEmail']);
+ $this->assertSame('maildev', $response['body']['smtpHost']);
+ $this->assertSame(1025, $response['body']['smtpPort']);
+ $this->assertSame('', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('', $response['body']['smtpSecure']);
+ $this->assertSame(3, $response['body']['pingCount']);
+
+ $this->assertIsString($response['body']['pingedAt']);
+ $this->assertNotEmpty($response['body']['pingedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['pingedAt']));
+
+ $this->assertCount(2, $response['body']['labels']);
+ $this->assertEquals('custom1', $response['body']['labels'][0]);
+ $this->assertEquals('custom2', $response['body']['labels'][1]);
+
+ $this->assertCount(2, $response['body']['devKeys']);
+ $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']);
+ $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']);
+ $this->assertEquals('2099-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']);
+ $this->assertEquals('2099-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']);
+
+ foreach ($response['body']['devKeys'] as $devKey) {
+ $this->assertIsString($devKey['$id']);
+ $this->assertNotEmpty($devKey['$id']);
+
+ $this->assertIsString($devKey['secret']);
+ $this->assertNotEmpty($devKey['secret']);
+
+ $this->assertIsString($devKey['accessedAt']);
+ $this->assertEmpty($devKey['accessedAt']);
+
+ $this->assertIsString($devKey['$createdAt']);
+ $this->assertNotEmpty($devKey['$createdAt']);
+ $this->assertNotFalse(\strtotime($devKey['$createdAt']));
+
+ $this->assertIsString($devKey['$updatedAt']);
+ $this->assertNotEmpty($devKey['$updatedAt']);
+ $this->assertNotFalse(\strtotime($devKey['$updatedAt']));
+
+ $this->assertIsArray($devKey['sdks']);
+ $this->assertCount(0, $devKey['sdks']);
+ }
+
+ $this->assertCount(2, $response['body']['authMockNumbers']);
+ $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']);
+ $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']);
+ $this->assertEquals('123456', $response['body']['authMockNumbers'][0]['otp']);
+ $this->assertEquals('654321', $response['body']['authMockNumbers'][1]['otp']);
+
+ foreach ($response['body']['authMockNumbers'] as $mockNumber) {
+ $this->assertIsString($mockNumber['$createdAt']);
+ $this->assertNotEmpty($mockNumber['$createdAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$createdAt']));
+
+ $this->assertIsString($mockNumber['$updatedAt']);
+ $this->assertNotEmpty($mockNumber['$updatedAt']);
+ $this->assertNotFalse(\strtotime($mockNumber['$updatedAt']));
+
+ $this->assertIsString($mockNumber['phone']);
+ $this->assertNotEmpty($mockNumber['phone']);
+
+ $this->assertIsString($mockNumber['otp']);
+ $this->assertNotEmpty($mockNumber['otp']);
+ }
+
+ $this->assertIsArray($response['body']['oAuthProviders']);
+ $this->assertGreaterThan(0, count($response['body']['oAuthProviders']));
+
+ $githubProvider = null;
+ foreach ($response['body']['oAuthProviders'] as $provider) {
+ $this->assertIsString($provider['key']);
+ $this->assertNotEmpty($provider['key']);
+
+ $this->assertIsString($provider['name']);
+ $this->assertIsString($provider['appId']);
+ $this->assertIsString($provider['secret']);
+ $this->assertIsBool($provider['enabled']);
+
+ if ($provider['key'] === 'github') {
+ $githubProvider = $provider;
+ }
+ }
+
+ $this->assertNotNull($githubProvider, 'GitHub provider not found');
+ $this->assertEquals('github-client-id', $githubProvider['appId']);
+ $this->assertEquals('', $githubProvider['secret']); // Write only
+ $this->assertEquals(false, $githubProvider['enabled']);
+
+ $this->assertIsArray($response['body']['platforms']);
+ $this->assertCount(1, $response['body']['platforms']);
+ $this->assertIsString($response['body']['platforms'][0]['$id']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$id']);
+ $this->assertEquals('Web App', $response['body']['platforms'][0]['name']);
+ $this->assertEquals('web', $response['body']['platforms'][0]['type']);
+ $this->assertEquals('localhost', $response['body']['platforms'][0]['hostname']);
+
+ $this->assertIsString($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$createdAt']));
+
+ $this->assertIsString($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['platforms'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('webhooks', $response['body']);
+ $this->assertIsArray($response['body']['webhooks']);
+ $this->assertCount(1, $response['body']['webhooks']);
+ $this->assertIsString($response['body']['webhooks'][0]['$id']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$id']);
+ $this->assertEquals('Webhook Test', $response['body']['webhooks'][0]['name']);
+ $this->assertEquals('https://appwrite.io', $response['body']['webhooks'][0]['url']);
+ $this->assertContains('users.*.create', $response['body']['webhooks'][0]['events']);
+ $this->assertContains('users.*.update.email', $response['body']['webhooks'][0]['events']);
+ $this->assertCount(2, $response['body']['webhooks'][0]['events']);
+ $this->assertTrue($response['body']['webhooks'][0]['tls']);
+ $this->assertEquals('username', $response['body']['webhooks'][0]['authUsername']);
+ $this->assertEquals('password', $response['body']['webhooks'][0]['authPassword']);
+ $this->assertTrue($response['body']['webhooks'][0]['enabled']);
+ $this->assertIsString($response['body']['webhooks'][0]['secret']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['secret']);
+ $this->assertIsString($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$createdAt']));
+ $this->assertIsString($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['webhooks'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$updatedAt']));
+
+ $this->assertArrayHasKey('keys', $response['body']);
+ $this->assertIsArray($response['body']['keys']);
+ $this->assertCount(1, $response['body']['keys']);
+ $this->assertIsString($response['body']['keys'][0]['$id']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$id']);
+ $this->assertEquals('Key Test', $response['body']['keys'][0]['name']);
+ $this->assertContains('teams.read', $response['body']['keys'][0]['scopes']);
+ $this->assertContains('teams.write', $response['body']['keys'][0]['scopes']);
+ $this->assertCount(2, $response['body']['keys'][0]['scopes']);
+ $this->assertNotEmpty($response['body']['keys'][0]['secret']);
+ $this->assertEmpty($response['body']['keys'][0]['accessedAt']);
+ $this->assertIsArray($response['body']['keys'][0]['sdks']);
+ $this->assertCount(0, $response['body']['keys'][0]['sdks']);
+ $this->assertIsString($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$createdAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$createdAt']));
+ $this->assertIsString($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotEmpty($response['body']['keys'][0]['$updatedAt']);
+ $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$updatedAt']));
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey);
+ }
+
+ // Ensure booleans can be falsy
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'userId' => false,
+ 'userEmail' => false,
+ 'userPhone' => false,
+ 'userName' => false,
+ 'userMFA' => false,
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()), [
+ 'enabled' => false
+ ]);
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ // Toggle auth methods, services, protocols
+
+ $authMethods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone'];
+ foreach ($authMethods as $authMethod) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/auth-methods/' . $authMethod,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $protocols = ['rest', 'graphql', 'websocket'];
+ foreach ($protocols as $protocol) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/protocols/' . $protocol,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ $services = [
+ 'account',
+ 'avatars',
+ 'databases',
+ 'tablesdb',
+ 'locale',
+ 'health',
+ 'project',
+ 'storage',
+ 'teams',
+ 'users',
+ 'vcs',
+ 'sites',
+ 'functions',
+ 'proxy',
+ 'graphql',
+ 'migrations',
+ 'messaging',
+ ];
+
+ foreach ($services as $service) {
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/services/' . $service,
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+ }
+
+ // Configure SMTP
+ $response = $this->client->call(
+ Client::METHOD_PATCH,
+ '/project/smtp',
+ array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $id,
+ 'x-appwrite-mode' => 'admin',
+ ], $this->getHeaders()),
+ [
+ 'enabled' => false,
+ 'host' => 'customhost.com',
+ 'port' => 4444,
+ 'username' => 'myuser',
+ 'password' => 'mypassword',
+ 'secure' => 'ssl',
+ ],
+ );
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+
+ $this->assertFalse($response['body']['authPasswordDictionary']);
+ $this->assertFalse($response['body']['authPersonalDataCheck']);
+ $this->assertFalse($response['body']['authSessionAlerts']);
+ $this->assertFalse($response['body']['authMembershipsUserName']);
+ $this->assertFalse($response['body']['authMembershipsUserEmail']);
+ $this->assertFalse($response['body']['authMembershipsMfa']);
+ $this->assertFalse($response['body']['authMembershipsUserId']);
+ $this->assertFalse($response['body']['authMembershipsUserPhone']);
+ $this->assertFalse($response['body']['authInvalidateSessions']);
+ $this->assertFalse($response['body']['smtpEnabled']);
+ $this->assertSame('customhost.com', $response['body']['smtpHost']);
+ $this->assertSame(4444, $response['body']['smtpPort']);
+ $this->assertSame('myuser', $response['body']['smtpUsername']);
+ $this->assertSame('', $response['body']['smtpPassword']); // Write only
+ $this->assertSame('ssl', $response['body']['smtpSecure']);
+
+ $authsKeys = [
+ 'authEmailPassword',
+ 'authUsersAuthMagicURL',
+ 'authEmailOtp',
+ 'authAnonymous',
+ 'authInvites',
+ 'authJWT',
+ 'authPhone',
+ ];
+ foreach ($authsKeys as $authsKey) {
+ $this->assertFalse($response['body'][$authsKey], 'Auth method should be disabled: ' . $authsKey);
+ }
+
+ $serviceKeys = [
+ 'serviceStatusForAccount',
+ 'serviceStatusForAvatars',
+ 'serviceStatusForDatabases',
+ 'serviceStatusForTablesdb',
+ 'serviceStatusForLocale',
+ 'serviceStatusForHealth',
+ 'serviceStatusForProject',
+ 'serviceStatusForStorage',
+ 'serviceStatusForTeams',
+ 'serviceStatusForUsers',
+ 'serviceStatusForVcs',
+ 'serviceStatusForSites',
+ 'serviceStatusForFunctions',
+ 'serviceStatusForProxy',
+ 'serviceStatusForGraphql',
+ 'serviceStatusForMigrations',
+ 'serviceStatusForMessaging',
+ ];
+ foreach ($serviceKeys as $serviceKey) {
+ $this->assertFalse($response['body'][$serviceKey], 'Service should be disabled: ' . $serviceKey);
+ }
+
+ $protocolKeys = [
+ 'protocolStatusForRest',
+ 'protocolStatusForGraphql',
+ 'protocolStatusForWebsocket',
+ ];
+ foreach ($protocolKeys as $protocolKey) {
+ $this->assertFalse($response['body'][$protocolKey], 'Protocol should be disabled: ' . $protocolKey);
+ }
+
/**
* Test for FAILURE
*/
diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php
index 29f7d70435..5e09031a9c 100644
--- a/tests/e2e/Services/Storage/StorageBase.php
+++ b/tests/e2e/Services/Storage/StorageBase.php
@@ -957,6 +957,68 @@ trait StorageBase
$this->assertNotEquals($imageBefore->getImageBlob(), $imageAfter->getImageBlob());
}
+ public function testFilePreviewCacheControlOnCacheHit(): void
+ {
+ $data = $this->setupBucketFile();
+ $bucketId = $data['bucketId'];
+ $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([
+ 'content-type' => 'multipart/form-data',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'fileId' => ID::unique(),
+ 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'),
+ 'permissions' => [
+ Permission::read(Role::any()),
+ Permission::update(Role::any()),
+ Permission::delete(Role::any()),
+ ],
+ ]);
+ $this->assertEquals(201, $file['headers']['status-code']);
+ $this->assertNotEmpty($file['body']['$id']);
+
+ $fileId = $file['body']['$id'];
+ $params = [
+ 'width' => 123,
+ 'height' => 45,
+ 'output' => 'png',
+ 'quality' => 80,
+ ];
+ $headers = array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders());
+
+ $preview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals(200, $preview['headers']['status-code']);
+ $this->assertEquals('image/png', $preview['headers']['content-type']);
+ $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']);
+ $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']);
+ $this->assertNotEmpty($preview['body']);
+
+ $cachedPreview = [];
+ $this->assertEventually(function () use (&$cachedPreview, $bucketId, $fileId, $headers, $params) {
+ $cachedPreview = $this->client->call(
+ Client::METHOD_GET,
+ '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview',
+ $headers,
+ $params
+ );
+
+ $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']);
+ });
+
+ $this->assertEquals(200, $cachedPreview['headers']['status-code']);
+ $this->assertEquals('image/png', $cachedPreview['headers']['content-type']);
+ $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']);
+ $this->assertEquals($preview['body'], $cachedPreview['body']);
+ }
+
public function testFilePreviewZstdCompression(): void
{
$data = $this->setupZstdCompressionBucket();
diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
index 854e7110f1..23007339de 100644
--- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php
+++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php
@@ -513,6 +513,59 @@ class VCSConsoleClientTest extends Scope
$this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
$this->assertEquals($repositoryBranches['body']['branches'][1]['name'], 'test');
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'search' => 'tes',
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 1);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::offset(1)->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'main']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test');
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::limit(1)->toString(),
+ Query::cursorBefore(new \Utopia\Database\Document(['$id' => 'test']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $repositoryBranches['headers']['status-code']);
+ $this->assertEquals($repositoryBranches['body']['total'], 2);
+ $this->assertCount(1, $repositoryBranches['body']['branches']);
+ $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main');
+
/**
* Test for FAILURE
*/
@@ -522,6 +575,16 @@ class VCSConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(404, $repositoryBranches['headers']['status-code']);
+
+ $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'missing-branch']))->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(400, $repositoryBranches['headers']['status-code']);
}
public function testCreateFunctionUsingVCS(): void
diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php
new file mode 100644
index 0000000000..29973089c6
--- /dev/null
+++ b/tests/unit/Vcs/CommentTest.php
@@ -0,0 +1,154 @@
+ 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ public function testTipIsRestoredFromParsedComment(): void
+ {
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $original = $comment->generateComment();
+ $originalTip = $this->extractTip($original);
+
+ $parsed = new Comment(['consoleHostname' => 'localhost']);
+ $parsed->parseComment($original);
+ $parsed->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func2', 'name' => 'Another Function']),
+ 'function',
+ 'building',
+ 'dep2',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $regenerated = $parsed->generateComment();
+ $regeneratedTip = $this->extractTip($regenerated);
+
+ $this->assertEquals($originalTip, $regeneratedTip);
+ }
+
+ public function testBackwardCompatibilityWithOldStateFormat(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+ $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($oldState);
+
+ $new = $comment->generateComment();
+ $newTip = $this->extractTip($new);
+
+ $this->assertNotNull($newTip);
+ $this->assertNotEquals('Old tip that should be ignored', $newTip);
+ $this->assertContains($newTip, $this->getTips());
+ }
+
+ public function testParseOldStateFormatWithOnlyBuilds(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($state);
+
+ $this->assertEquals(false, $comment->isEmpty());
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+ $this->assertContains($firstTip, $this->getTips());
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ private function extractTip(string $comment): ?string
+ {
+ if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ private function getTips(): array
+ {
+ $reflection = new \ReflectionClass(Comment::class);
+ $property = $reflection->getProperty('tips');
+
+ return $property->getValue(new Comment(['consoleHostname' => 'localhost']));
+ }
+}