Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/Migration/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Utopia\Migration\Resources\Database\Row;
use Utopia\Migration\Resources\Database\Table;
use Utopia\Migration\Resources\Functions\Deployment;
use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment;
use Utopia\Migration\Resources\Storage\File;

/**
Expand Down Expand Up @@ -69,6 +70,11 @@ public function resolveResourceCacheKey(Resource $resource): string
$keys[] = $resource->getFunction()->getSequence();
break;

case Resource::TYPE_SITE_DEPLOYMENT:
/** @var SiteDeployment $resource */
$keys[] = $resource->getSite()->getSequence();
break;

default:
break;
}
Expand Down Expand Up @@ -99,8 +105,8 @@ public function add(Resource $resource): void
return;
}

if ($resource->getName() == Resource::TYPE_FILE || $resource->getName() == Resource::TYPE_DEPLOYMENT) {
/** @var File|Deployment $resource */
if ($resource->getName() == Resource::TYPE_FILE || $resource->getName() == Resource::TYPE_DEPLOYMENT || $resource->getName() == Resource::TYPE_SITE_DEPLOYMENT) {
/** @var File|Deployment|SiteDeployment $resource */
$resource->setData(''); // Prevent Memory Leak
}

Expand Down
225 changes: 225 additions & 0 deletions src/Migration/Destinations/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@

use Appwrite\AppwriteException;
use Appwrite\Client;
use Appwrite\Enums\Adapter;
use Appwrite\Enums\BuildRuntime;
use Appwrite\Enums\Compression;
use Appwrite\Enums\Framework;
use Appwrite\Enums\PasswordHash;
use Appwrite\Enums\Runtime;
use Appwrite\InputFile;
use Appwrite\Services\Functions;
use Appwrite\Services\Sites;
use Appwrite\Services\Storage;
use Appwrite\Services\Teams;
use Appwrite\Services\Users;
Expand Down Expand Up @@ -42,6 +46,9 @@
use Utopia\Migration\Resources\Functions\Deployment;
use Utopia\Migration\Resources\Functions\EnvVar;
use Utopia\Migration\Resources\Functions\Func;
use Utopia\Migration\Resources\Sites\Deployment as SiteDeployment;
use Utopia\Migration\Resources\Sites\EnvVar as SiteEnvVar;
use Utopia\Migration\Resources\Sites\Site;
use Utopia\Migration\Resources\Storage\Bucket;
use Utopia\Migration\Resources\Storage\File;
use Utopia\Migration\Transfer;
Expand All @@ -54,6 +61,7 @@ class Appwrite extends Destination
protected string $key;

private Functions $functions;
private Sites $sites;
private Storage $storage;
private Teams $teams;
private Users $users;
Expand Down Expand Up @@ -87,6 +95,7 @@ public function __construct(
->setKey($key);

$this->functions = new Functions($this->client);
$this->sites = new Sites($this->client);
$this->storage = new Storage($this->client);
$this->teams = new Teams($this->client);
$this->users = new Users($this->client);
Expand Down Expand Up @@ -128,6 +137,11 @@ public static function getSupportedResources(): array
Resource::TYPE_FUNCTION,
Resource::TYPE_DEPLOYMENT,
Resource::TYPE_ENVIRONMENT_VARIABLE,

// Sites
Resource::TYPE_SITE,
Resource::TYPE_SITE_DEPLOYMENT,
Resource::TYPE_SITE_VARIABLE,
];
}

Expand Down Expand Up @@ -199,6 +213,15 @@ public function report(array $resources = [], array $resourceIds = []): array
$this->functions->create('', '', Runtime::NODE180());
}

// Sites
if (\in_array(Resource::TYPE_SITE, $resources)) {
$scope = 'sites.read';
$this->sites->list();

$scope = 'sites.write';
$this->sites->create('', '', Framework::OTHER(), BuildRuntime::STATIC1());
}

} catch (AppwriteException $e) {
if ($e->getCode() === 403) {
throw new \Exception('Missing scope: ' . $scope, previous: $e);
Expand Down Expand Up @@ -236,6 +259,7 @@ protected function import(array $resources, callable $callback): void
Transfer::GROUP_STORAGE => $this->importFileResource($resource),
Transfer::GROUP_AUTH => $this->importAuthResource($resource),
Transfer::GROUP_FUNCTIONS => $this->importFunctionResource($resource),
Transfer::GROUP_SITES => $this->importSiteResource($resource),
default => throw new \Exception('Invalid resource group'),
};
} catch (\Throwable $e) {
Expand Down Expand Up @@ -1505,4 +1529,205 @@ private function importDeployment(Deployment $deployment): Resource

return $deployment;
}

/**
* @throws AppwriteException
*/
public function importSiteResource(Resource $resource): Resource
{
switch ($resource->getName()) {
case Resource::TYPE_SITE:
/** @var Site $resource */

$buildRuntime = match ($resource->getBuildRuntime()) {
'node-14.5' => BuildRuntime::NODE145(),
'node-16.0' => BuildRuntime::NODE160(),
'node-18.0' => BuildRuntime::NODE180(),
'node-19.0' => BuildRuntime::NODE190(),
'node-20.0' => BuildRuntime::NODE200(),
'node-21.0' => BuildRuntime::NODE210(),
'node-22' => BuildRuntime::NODE22(),
'php-8.0' => BuildRuntime::PHP80(),
'php-8.1' => BuildRuntime::PHP81(),
'php-8.2' => BuildRuntime::PHP82(),
'php-8.3' => BuildRuntime::PHP83(),
'ruby-3.0' => BuildRuntime::RUBY30(),
'ruby-3.1' => BuildRuntime::RUBY31(),
'ruby-3.2' => BuildRuntime::RUBY32(),
'ruby-3.3' => BuildRuntime::RUBY33(),
'python-3.8' => BuildRuntime::PYTHON38(),
'python-3.9' => BuildRuntime::PYTHON39(),
'python-3.10' => BuildRuntime::PYTHON310(),
'python-3.11' => BuildRuntime::PYTHON311(),
'python-3.12' => BuildRuntime::PYTHON312(),
'python-ml-3.11' => BuildRuntime::PYTHONML311(),
'python-ml-3.12' => BuildRuntime::PYTHONML312(),
'dart-3.0' => BuildRuntime::DART30(),
'dart-3.1' => BuildRuntime::DART31(),
'dart-3.3' => BuildRuntime::DART33(),
'dart-3.5' => BuildRuntime::DART35(),
'dart-3.8' => BuildRuntime::DART38(),
'dart-3.9' => BuildRuntime::DART39(),
'dart-2.15' => BuildRuntime::DART215(),
'dart-2.16' => BuildRuntime::DART216(),
'dart-2.17' => BuildRuntime::DART217(),
'dart-2.18' => BuildRuntime::DART218(),
'dart-2.19' => BuildRuntime::DART219(),
'deno-1.21' => BuildRuntime::DENO121(),
'deno-1.24' => BuildRuntime::DENO124(),
'deno-1.35' => BuildRuntime::DENO135(),
'deno-1.40' => BuildRuntime::DENO140(),
'deno-1.46' => BuildRuntime::DENO146(),
'deno-2.0' => BuildRuntime::DENO20(),
'dotnet-6.0' => BuildRuntime::DOTNET60(),
'dotnet-7.0' => BuildRuntime::DOTNET70(),
'dotnet-8.0' => BuildRuntime::DOTNET80(),
'java-8.0' => BuildRuntime::JAVA80(),
'java-11.0' => BuildRuntime::JAVA110(),
'java-17.0' => BuildRuntime::JAVA170(),
'java-18.0' => BuildRuntime::JAVA180(),
'java-21.0' => BuildRuntime::JAVA210(),
'java-22' => BuildRuntime::JAVA22(),
'swift-5.5' => BuildRuntime::SWIFT55(),
'swift-5.8' => BuildRuntime::SWIFT58(),
'swift-5.9' => BuildRuntime::SWIFT59(),
'swift-5.10' => BuildRuntime::SWIFT510(),
'kotlin-1.6' => BuildRuntime::KOTLIN16(),
'kotlin-1.8' => BuildRuntime::KOTLIN18(),
'kotlin-1.9' => BuildRuntime::KOTLIN19(),
'kotlin-2.0' => BuildRuntime::KOTLIN20(),
'cpp-17' => BuildRuntime::CPP17(),
'cpp-20' => BuildRuntime::CPP20(),
'bun-1.0' => BuildRuntime::BUN10(),
'bun-1.1' => BuildRuntime::BUN11(),
'go-1.23' => BuildRuntime::GO123(),
'static-1' => BuildRuntime::STATIC1(),
'flutter-3.24' => BuildRuntime::FLUTTER324(),
'flutter-3.27' => BuildRuntime::FLUTTER327(),
'flutter-3.29' => BuildRuntime::FLUTTER329(),
'flutter-3.32' => BuildRuntime::FLUTTER332(),
'flutter-3.35' => BuildRuntime::FLUTTER335(),
default => throw new \Exception('Invalid Build Runtime: ' . $resource->getBuildRuntime()),
};

$framework = match ($resource->getFramework()) {
'analog' => Framework::ANALOG(),
'angular' => Framework::ANGULAR(),
'astro' => Framework::ASTRO(),
'flutter', 'flutter-web' => Framework::FLUTTER(),
'lynx' => Framework::LYNX(),
'nextjs' => Framework::NEXTJS(),
'nuxt' => Framework::NUXT(),
'react' => Framework::REACT(),
'react-native' => Framework::REACTNATIVE(),
'remix' => Framework::REMIX(),
'svelte-kit' => Framework::SVELTEKIT(),
'tanstack-start' => Framework::TANSTACKSTART(),
'vite' => Framework::VITE(),
'vue' => Framework::VUE(),
default => Framework::OTHER(),
};

$adapter = match ($resource->getAdapter()) {
'static' => Adapter::STATIC(),
'ssr' => Adapter::SSR(),
default => null,
};

$this->sites->create(
$resource->getId(),
$resource->getSiteName(),
$framework,
$buildRuntime,
$resource->getEnabled(),
$resource->getLogging(),
$resource->getTimeout(),
$resource->getInstallCommand(),
$resource->getBuildCommand(),
$resource->getOutputDirectory(),
$adapter,
fallbackFile: $resource->getFallbackFile(),
specification: $resource->getSpecification(),
);
break;
case Resource::TYPE_SITE_VARIABLE:
/** @var SiteEnvVar $resource */
$this->sites->createVariable(
$resource->getSite()->getId(),
$resource->getKey(),
$resource->getValue()
);
break;
case Resource::TYPE_SITE_DEPLOYMENT:
/** @var SiteDeployment $resource */
return $this->importSiteDeployment($resource);
}

$resource->setStatus(Resource::STATUS_SUCCESS);

return $resource;
}

/**
* @throws AppwriteException
* @throws \Exception
*/
private function importSiteDeployment(SiteDeployment $deployment): Resource
{
$siteId = $deployment->getSite()->getId();

if ($deployment->getSize() <= Transfer::STORAGE_MAX_CHUNK_SIZE) {
$response = $this->client->call(
'POST',
"/sites/{$siteId}/deployments",
[
'content-type' => 'multipart/form-data',
],
[
'siteId' => $siteId,
'code' => new \CURLFile('data://application/gzip;base64,' . base64_encode($deployment->getData()), 'application/gzip', 'deployment.tar.gz'),
'activate' => $deployment->getActivated(),
]
);

if (!\is_array($response) || !isset($response['$id'])) {
throw new \Exception('Site deployment creation failed');
}

$deployment->setStatus(Resource::STATUS_SUCCESS);

return $deployment;
}

$response = $this->client->call(
'POST',
"/sites/{$siteId}/deployments",
[
'content-type' => 'multipart/form-data',
'content-range' => 'bytes ' . ($deployment->getStart()) . '-' . ($deployment->getEnd() == ($deployment->getSize() - 1) ? $deployment->getSize() : $deployment->getEnd()) . '/' . $deployment->getSize(),
'x-appwrite-id' => $deployment->getId(),
],
[
'siteId' => $siteId,
'code' => new \CURLFile('data://application/gzip;base64,' . base64_encode($deployment->getData()), 'application/gzip', 'deployment.tar.gz'),
'activate' => $deployment->getActivated(),
]
);

if (!\is_array($response) || !isset($response['$id'])) {
throw new \Exception('Site deployment creation failed');
}

if ($deployment->getStart() === 0) {
$deployment->setId($response['$id']);
}

if ($deployment->getEnd() == ($deployment->getSize() - 1)) {
$deployment->setStatus(Resource::STATUS_SUCCESS);
} else {
$deployment->setStatus(Resource::STATUS_PENDING);
}

return $deployment;
}
Comment on lines +1675 to +1732
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Appwrite Sites deployments chunked upload API content-range x-appwrite-id

💡 Result:

For Appwrite Sites deployments (POST /v1/sites/{siteId}/deployments), the code field is a gzip file sent as multipart/form-data. (appwrite.io)

If your deployment archive is > 5MB, Appwrite’s upload endpoints (including Create Deployment) support resumable 5MB chunked uploads using these headers: (appwrite.io)

  • Content-Range: bytes <first>-<last>/<total> (byte offsets for the current chunk) (appwrite.io)
  • X-Appwrite-ID: <ID> (the upload/deployment/file ID that subsequent chunks belong to) (appwrite.io)

Notes:

  • Header names are case-insensitive, so x-appwrite-id works the same as X-Appwrite-ID in practice. (appwrite.io)
  • Pattern is: send the first chunk (server returns an object/ID), then send remaining chunks with the same Content-Range scheme and X-Appwrite-ID set to that returned ID. (appwrite.io)

Citations:


Fix Content-Range header calculation for final chunk in chunked deployment uploads.

The chunked upload contract (headers and endpoint) aligns with Appwrite's documented API for Sites deployments. However, the Content-Range header has a bug: when constructing the final chunk, the code incorrectly uses $deployment->getSize() as the end byte position instead of $deployment->getSize() - 1.

Per RFC 7233, the end position must be an inclusive 0-based byte offset. For a 1000-byte file, the final chunk should send bytes 800-999/1000, not bytes 800-1000/1000. Change the conditional:

'content-range' => 'bytes ' . ($deployment->getStart()) . '-' . ($deployment->getEnd() == ($deployment->getSize() - 1) ? $deployment->getSize() - 1 : $deployment->getEnd()) . '/' . $deployment->getSize(),
🤖 Prompt for AI Agents
In `@src/Migration/Destinations/Appwrite.php` around lines 1675 - 1732, The
Content-Range header in importSiteDeployment is incorrectly computing the final
chunk end as getSize() instead of the inclusive byte offset getSize() - 1;
update the header construction in the client->call ('content-range' value) to
use ($deployment->getEnd() == ($deployment->getSize() - 1) ?
$deployment->getSize() - 1 : $deployment->getEnd()) so the final chunk is "bytes
start-(size-1)/size" per RFC 7233; locate this change in importSiteDeployment
where the client->call headers are built (look for 'content-range' and methods
getStart(), getEnd(), getSize()).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the specific server-side code that handles Content-Range parsing
curl -s "https://raw.githubusercontent.com/appwrite/appwrite/f0191202a08d156db43d2b14bd6a0a191ef6f996/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php" | sed -n '177,186p'

Length of output: 609


@premtsd-code, you're absolutely right that the Appwrite server currently handles this! The server-side code shows it accepts both formats:

if ($end === $fileSize - 1 || $end === $fileSize) {

However, I noticed the TODO comment in that same code:

// TODO remove the condition that checks $end === $fileSize in next breaking version

This indicates the server is only accepting $end === $fileSize for backward compatibility and plans to remove it in a future breaking version. The migration code is currently sending the non-RFC-compliant format that will eventually be unsupported.

Recommendation: While it works today, I'd suggest updating the migration code to use the RFC 7233-compliant format ($fileSize - 1) to future-proof against the planned server-side change. This would align with the importDeployment method for functions (lines 1505-1529), which uses the same pattern and would benefit from the same fix.

Would you like to update this proactively, or prefer to wait until the server-side breaking change is announced?


🧬 Code Graph Analysis Results

src/Migration/Resources/Sites/Deployment.php

### File: `src/Migration/Resources/Sites/Deployment.php` ``` class Deployment extends Resource { public function __construct( string $id, private readonly Func $func, private readonly int $size, private int $start = 0, private int $end = 0, private string $data = '', private readonly bool $activated = false ) { $this->id = $id; }
/**
 * `@param` array<string, mixed> $array
 * `@return` self
 */
public static function fromArray(array $array): self
{
    return new self(
        $array['id'],
        Func::fromArray($array['func']),
        $array['size'],
        $array['start'] ?? 0,
        $array['end'] ?? 0,
        $array['data'] ?? '',
        $array['activated'] ?? false
    );
}

/**
 * `@return` array<string, mixed>
 */
public function jsonSerialize(): array
{
    return [
        'id' => $this->id,
        'func' => $this->func,
        'size' => $this->size,
        'start' => $this->start,
        'end' => $this->end,
        'data' => $this->data,
        'activated' => $this->activated,
    ];
}

public static function getName(): string
{
    return Resource::TYPE_DEPLOYMENT;
}

public function getGroup(): string
{
    return Transfer::GROUP_FUNCTIONS;
}

public function getFunction(): Func
{
    return $this->func;
}

public function getSize(): int
{
    return $this->size;
}

public function getEntrypoint(): string
{
    return $this->entrypoint;
}

public function setStart(int $start): self
{
    $this->start = $start;

    return $this;
}

public function getStart(): int
{
    return $this->start;
}

public function setEnd(int $end): self
{
    $this->end = $end;

    return $this;
}

public function getEnd(): int
{
    return $this->end;
}

public function setData(string $data): self
{
    $this->data = $data;

    return $this;
}

public function getData(): string
{
    return $this->data;
}

public function getActivated(): bool
{
    return $this->activated;
}

}

</snippet>

<filename>src/Migration/Resources/Sites/Deployment.php</filename>

<snippet>
### File: `src/Migration/Resources/Sites/Deployment.php`

class Deployment extends Resource
{
public function __construct(
string $id,
private readonly Func $func,
private readonly int $size,
private int $start = 0,
private int $end = 0,
private string $data = '',
private readonly bool $activated = false
) {
$this->id = $id;
}

/**
 * `@param` array<string, mixed> $array
 * `@return` self
 */
public static function fromArray(array $array): self
{
    return new self(
        $array['id'],
        Func::fromArray($array['func']),
        $array['size'],
        $array['start'] ?? 0,
        $array['end'] ?? 0,
        $array['data'] ?? '',
        $array['activated'] ?? false
    );
}

/**
 * `@return` array<string, mixed>
 */
public function jsonSerialize(): array
{
    return [
        'id' => $this->id,
        'func' => $this->func,
        'size' => $this->size,
        'start' => $this->start,
        'end' => $this->end,
        'data' => $this->data,
        'activated' => $this->activated,
    ];
}

public static function getName(): string
{
    return Resource::TYPE_SITE_DEPLOYMENT;
}

public function getGroup(): string
{
    return Transfer::GROUP_SITES;
}

public function getSite(): Site
{
    return $this->site;
}

public function getSize(): int
{
    return $this->size;
}

public function setStart(int $start): self
{
    $this->start = $start;

    return $this;
}

public function getStart(): int
{
    return $this->start;
}

public function setEnd(int $end): self
{
    $this->end = $end;

    return $this;
}

public function getEnd(): int
{
    return $this->end;
}

public function setData(string $data): self
{
    $this->data = $data;

    return $this;
}

public function getData(): string
{
    return $this->data;
}

public function getActivated(): bool
{
    return $this->activated;
}

}

</snippet>

</details>



---

<details>
<summary>🧠 Learnings used</summary>

Learnt from: premtsd-code
Repo: utopia-php/migration PR: 142
File: src/Migration/Destinations/Appwrite.php:1542-1635
Timestamp: 2026-02-03T15:50:17.680Z
Learning: This repository targets Appwrite PHP SDK v19, and the migration code relies on enum classes Appwrite\Enums\Framework, Appwrite\Enums\BuildRuntime, and Appwrite\Enums\Adapter being present in vendor/appwrite/appwrite/src/Appwrite/Enums. The Sites mappings in src/Migration/Destinations/Appwrite.php intentionally follow the same enum pattern used for Functions.


</details>



<!-- This is an auto-generated reply by CodeRabbit -->

}
9 changes: 9 additions & 0 deletions src/Migration/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ abstract class Resource implements \JsonSerializable

public const TYPE_FUNCTION = 'function';

public const TYPE_SITE = 'site';

public const TYPE_INDEX = 'index';

// Children (Resources that are created by other resources)
Expand All @@ -50,6 +52,10 @@ abstract class Resource implements \JsonSerializable

public const TYPE_DEPLOYMENT = 'deployment';

public const TYPE_SITE_DEPLOYMENT = 'site-deployment';

public const TYPE_SITE_VARIABLE = 'site-variable';

public const TYPE_HASH = 'hash';

public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable';
Expand All @@ -74,6 +80,9 @@ abstract class Resource implements \JsonSerializable
self::TYPE_FILE,
self::TYPE_FUNCTION,
self::TYPE_DEPLOYMENT,
self::TYPE_SITE,
self::TYPE_SITE_DEPLOYMENT,
self::TYPE_SITE_VARIABLE,
self::TYPE_HASH,
self::TYPE_INDEX,
self::TYPE_USER,
Expand Down
Loading