From d1dbe795f41ed8d0307f6cf092860dfe4188c8b7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 11:07:55 +0530 Subject: [PATCH 01/37] fix: prepare self-hosted 2.0 release --- README-CN.md | 6 +- README.md | 8 +-- app/init/constants.php | 4 +- src/Appwrite/Docker/Compose/Generator.php | 63 ++++++++++++--------- tests/unit/Docker/Compose/GeneratorTest.php | 14 +++-- 5 files changed, 53 insertions(+), 42 deletions(-) diff --git a/README-CN.md b/README-CN.md index e05e6e2849d..f7dd66e9c92 100644 --- a/README-CN.md +++ b/README-CN.md @@ -71,7 +71,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` ### Windows @@ -83,7 +83,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` #### PowerShell @@ -93,7 +93,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` 运行后,可以在浏览器上访问 http://localhost 找到 Appwrite 控制台。在非 Linux 的本机主机上完成安装后,服务器可能需要几分钟才能启动。 diff --git a/README.md b/README.md index 149dbf51ae6..c9f522bf239 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` ### Windows @@ -89,7 +89,7 @@ docker run -it --rm ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` #### PowerShell @@ -100,7 +100,7 @@ docker run -it --rm ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` Once the Docker installation is complete, go to http://localhost to access the Appwrite console from your browser. Please note that on non-Linux native hosts, the server might take a few minutes to start after completing the installation. @@ -116,7 +116,7 @@ docker run -it --rm \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ - appwrite/appwrite:1.9.6 + appwrite/appwrite:2.0.0 ``` Use the same `--env DOCKER_API_VERSION=...` flag with `--entrypoint="upgrade"` when upgrading. diff --git a/app/init/constants.php b/app/init/constants.php index fca02065fd3..7ff8931b44a 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -54,8 +54,8 @@ const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4326; -const APP_VERSION_STABLE = '1.9.6'; +const APP_CACHE_BUSTER = 4327; +const APP_VERSION_STABLE = '2.0.0'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; const APP_DATABASE_ATTRIBUTE_IP = 'ip'; diff --git a/src/Appwrite/Docker/Compose/Generator.php b/src/Appwrite/Docker/Compose/Generator.php index 83534bb8984..316d1c7a397 100644 --- a/src/Appwrite/Docker/Compose/Generator.php +++ b/src/Appwrite/Docker/Compose/Generator.php @@ -37,25 +37,9 @@ class Generator 'appwrite-worker', 'appwrite-task-scheduler', ], - 'separate' => [ - 'appwrite-worker-webhooks', - 'appwrite-worker-deletes', - 'appwrite-worker-databases', - 'appwrite-worker-builds', - 'appwrite-worker-jobs', - 'appwrite-worker-screenshots', - 'appwrite-worker-certificates', - 'appwrite-worker-executions', - 'appwrite-worker-functions', - 'appwrite-worker-mails', - 'appwrite-worker-notifications', - 'appwrite-worker-messaging', - 'appwrite-worker-migrations', - 'appwrite-task-scheduler-functions', - 'appwrite-task-scheduler-executions', - 'appwrite-task-scheduler-messages', - ], + 'separate' => [], ], + 'profile' => 'separate', ], ]; @@ -210,22 +194,47 @@ private function filterServices(array $services): array foreach (self::TOPOLOGY_SERVICE_GROUPS as $param => $config) { $selected = $this->params[$param]; - foreach ($config['modes'] as $mode => $names) { - if ($mode === $selected) { + $profile = $config['profile']; + $combined = $config['modes']['combined']; + + if ($selected === 'separate') { + foreach ($services as &$service) { + if (!\is_array($service)) { + continue; + } + + $base = $service['extends']['service'] ?? null; + if (!\is_string($base) || !\in_array($base, $combined, true) || !isset($services[$base])) { + continue; + } + + $service = \array_replace_recursive($services[$base], $service); + unset($service['extends'], $service['profiles']); + } + unset($service); + } + + foreach ($services as $name => &$service) { + if (!\is_array($service)) { continue; } - foreach ($names as $name) { + + $profiles = $service['profiles'] ?? []; + if ($selected === 'combined' && \in_array($profile, $profiles, true)) { unset($services[$name]); + continue; } - } - } - foreach ($services as &$service) { - if (\is_array($service)) { - unset($service['profiles']); + if ($selected === 'separate') { + if (\in_array($name, $combined, true)) { + $service['profiles'] = ['combined']; + } elseif (\in_array($profile, $profiles, true)) { + unset($service['profiles']); + } + } } + unset($service); } - unset($service); return $services; } diff --git a/tests/unit/Docker/Compose/GeneratorTest.php b/tests/unit/Docker/Compose/GeneratorTest.php index 2305d0dedad..8b0f259aafb 100644 --- a/tests/unit/Docker/Compose/GeneratorTest.php +++ b/tests/unit/Docker/Compose/GeneratorTest.php @@ -70,10 +70,6 @@ public function testKeepsProductionWorkers(): void $this->assertArrayHasKey('appwrite-embedding', $compose['services']); $this->assertArrayNotHasKey('profiles', $compose['services']['appwrite-worker']); $this->assertArrayNotHasKey('profiles', $compose['services']['appwrite-task-scheduler']); - $this->assertArrayNotHasKey('appwrite-worker-screenshots', $compose['services']); - $this->assertArrayNotHasKey('appwrite-worker-executions', $compose['services']); - $this->assertArrayNotHasKey('appwrite-worker-functions', $compose['services']); - $this->assertArrayNotHasKey('appwrite-task-scheduler-functions', $compose['services']); } public function testSelectsSeparateTopology(): void @@ -82,14 +78,20 @@ public function testSelectsSeparateTopology(): void 'topology' => 'separate', ]); - $this->assertArrayNotHasKey('appwrite-worker', $compose['services']); - $this->assertArrayNotHasKey('appwrite-task-scheduler', $compose['services']); + $this->assertSame(['combined'], $compose['services']['appwrite-worker']['profiles']); + $this->assertSame(['combined'], $compose['services']['appwrite-task-scheduler']['profiles']); $this->assertArrayHasKey('appwrite-worker-screenshots', $compose['services']); $this->assertArrayHasKey('appwrite-worker-executions', $compose['services']); $this->assertArrayHasKey('appwrite-worker-functions', $compose['services']); $this->assertArrayHasKey('appwrite-task-scheduler-functions', $compose['services']); $this->assertArrayNotHasKey('profiles', $compose['services']['appwrite-worker-functions']); $this->assertArrayNotHasKey('profiles', $compose['services']['appwrite-task-scheduler-functions']); + + foreach (['appwrite-worker-stats-usage', 'appwrite-worker-stats-resources', 'appwrite-task-stats-resources'] as $name) { + $this->assertArrayHasKey($name, $compose['services']); + $this->assertArrayNotHasKey('extends', $compose['services'][$name]); + $this->assertArrayNotHasKey('profiles', $compose['services'][$name]); + } } public function testKeepsMongoInitFiles(): void From 17b4f69b9e2aed88c07c18364971cb5a62cfbf74 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 14:07:40 +0530 Subject: [PATCH 02/37] fix: persist postgres data by mounting the volume at PGDATA appwrite/postgres:0.1.0 declares VOLUME /var/lib/postgresql and sets PGDATA=/var/lib/postgresql/18/docker, but the compose file mounted the named volume at /var/lib/postgresql/18/data - a sibling of PGDATA, not PGDATA itself. Docker therefore satisfied the declared VOLUME with a fresh anonymous volume on every container creation, and that anonymous volume is where all Postgres data actually lived. The named appwrite-postgresql volume stayed empty, so data survived a restart but was discarded by any container recreate: docker compose down, up --force-recreate, an image bump, or the documented upgrade entrypoint. Postgres is the default platform database, so a stock install lost its console users, projects and data on the first upgrade. Mount the named volume at /var/lib/postgresql so it covers PGDATA. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 61dec153c13..d9a95e1c705 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1691,7 +1691,7 @@ services: networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql/18/data:rw + - appwrite-postgresql:/var/lib/postgresql:rw environment: - POSTGRES_DB=${_APP_DB_SCHEMA} - POSTGRES_USER=${_APP_DB_USER} From 5ee82001fccd1db508a139603bfbc8e136141280 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 14:18:00 +0530 Subject: [PATCH 03/37] fix: warn when upgrade ignores the requested database The upgrade task accepts --database but always re-derives the engine from the existing installation and locks it, so the flag was silently discarded. Someone running `upgrade --database=postgresql` on a 1.9.x MongoDB install got no indication that the request had been dropped and that their platform database was still MongoDB. Default the parameter to an empty string so an explicit value can be told apart from the default, and warn when it differs from the detected engine. The warning also states that Appwrite cannot move an existing installation between database engines, since there is no supported path for it. Behaviour is unchanged: the detected engine is still what the upgrade uses. --- src/Appwrite/Platform/Tasks/Upgrade.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index c4f110c13d7..e161f62beb5 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -30,7 +30,7 @@ public function __construct() ->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true) ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) - ->param('database', 'mongodb', new Text(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true) + ->param('database', '', new Text(length: 0, min: 0), 'Ignored: an upgrade always keeps the database the installation already uses', true) ->param('topology', 'combined', new WhiteList(['combined', 'separate']), 'Worker and scheduler topology (combined|separate)', true) ->param('migrate', false, new Boolean(true), 'Run database migration after upgrade', true) ->callback($this->action(...)); @@ -64,7 +64,9 @@ public function action( return; } - // Detect database from existing installation (CLI param is intentionally ignored) + // An upgrade always keeps the engine the installation already uses, so the + // requested value is only kept to tell the user it is being ignored. + $requestedDatabase = $database; $database = null; $compose = new Compose($data); foreach ($compose->getServices() as $service) { @@ -89,6 +91,14 @@ public function action( Console::info('No _APP_DB_ADAPTER found in existing configuration, defaulting to mariadb.'); } + if ($requestedDatabase !== '' && $requestedDatabase !== $database) { + Console::warning( + "Ignoring --database={$requestedDatabase}: this installation uses {$database} and an upgrade preserves it." + . " Appwrite cannot move an existing installation between database engines;" + . " switching requires a new installation and transferring your data into it." + ); + } + $this->lockedDatabase = $database; parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database, $topology); From 5216b1bc92ba1f37ad0f522f9c6876468dbca118 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 14:45:41 +0530 Subject: [PATCH 04/37] fix: bump APP_CACHE_BUSTER for the 2.0.0 release APP_CACHE_BUSTER was raised from 4326 to 4327 when this branch was prepared for 2.0.0, but 1.9.6 had already shipped 4327. The two releases therefore carried the same value. The constant is mixed into Request::cacheIdentifier(), so an installation upgrading from 1.9.6 kept serving responses cached under 1.9.6 keys instead of invalidating them. Bump to 4328 so 2.0.0 has a value no earlier release has used. --- app/init/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/constants.php b/app/init/constants.php index 7ff8931b44a..7b715de7a1c 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -54,7 +54,7 @@ const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4327; +const APP_CACHE_BUSTER = 4328; const APP_VERSION_STABLE = '2.0.0'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; From fde7cbcb9a673753ef5e224d58190b92a30c866f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 14:49:35 +0530 Subject: [PATCH 05/37] fix: migrate onboarding and schedule project id in V25 Two console schema changes made since 1.9.6 never reached upgraded installations. The boot-time sync in app/http.php only creates collections that do not exist yet, so it skips new attributes on collections that are already there, and V25 had no case for either one: - projects.onboarding, written by the API shutdown hook on every matching request and read by the console "Get started" checklist. Because the platform handle drops unknown attributes, the write was discarded silently and the checklist stayed empty forever. - schedules.projectInternalId and its two indexes, written by Projects/Http/Schedules/Create.php on every schedule create. Add both to V25 using the same guarded style as the surrounding cases, so re-running the migration warns instead of aborting. Verified by upgrading a seeded 1.9.6 install to 2.0.0 on MariaDB: both columns go from absent to present, seeded data survives, and a second migrate run is a no-op. --- src/Appwrite/Migration/Version/V25.php | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/Appwrite/Migration/Version/V25.php b/src/Appwrite/Migration/Version/V25.php index 507037e95b8..f3aab601b44 100644 --- a/src/Appwrite/Migration/Version/V25.php +++ b/src/Appwrite/Migration/Version/V25.php @@ -61,6 +61,12 @@ private function migrateCollections(): void switch ($id) { case 'projects': if ($collectionType === 'console') { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'onboarding'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"onboarding\" in collection {$id}: {$th->getMessage()}"); + } + try { $this->createIndexFromCollection($this->dbForProject, $id, '_key_accessedAt'); } catch (Throwable $th) { @@ -70,6 +76,31 @@ private function migrateCollections(): void $this->dbForProject->purgeCachedCollection($id); break; + case 'schedules': + if ($collectionType === 'console') { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'projectInternalId'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"projectInternalId\" in collection {$id}: {$th->getMessage()}"); + } + + $this->dbForProject->purgeCachedCollection($id); + + $indexes = [ + '_key_region_resourceType_projectInternalId_resourceId', + '_key_project_internal_id_region', + ]; + foreach ($indexes as $index) { + try { + $this->createIndexFromCollection($this->dbForProject, $id, $index); + } catch (Throwable $th) { + Console::warning("Failed to create index \"{$index}\" from {$id}: {$th->getMessage()}"); + } + } + } + $this->dbForProject->purgeCachedCollection($id); + break; + case 'installations': if ($collectionType === 'console') { foreach (['personalAccessToken', 'personalRefreshToken'] as $attribute) { From d9bc224d21cc74c6348ddbcae5e83d7fb1d7ede7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 14:56:28 +0530 Subject: [PATCH 06/37] fix: make DocumentsDB and VectorsDB connections configurable DocumentsDB is backed only by MongoDB and VectorsDB only by PostgreSQL, so an installation that uses either needs that engine reachable. The connection details were effectively hardcoded: - docker-compose.yml never listed the _APP_DB_*_DOCUMENTSDB variables, and listed no _APP_CONNECTIONS_DATABASE_* variables at all. Compose only forwards variables it names, so setting them in .env never reached the containers and both pools always fell back to mongodb:27017 and postgresql:5432. - Neither family was described in app/config/variables.php, so the installer never wrote them to .env either. - registers.php read _APP_DB_USER, _APP_DB_PASS and _APP_DB_SCHEMA for both pools, so the per-engine credential variables that compose did forward for VectorsDB were never consumed. Forward both families plus the two connection-string overrides on every service that resolves these pools, describe them in variables.php, and read the per-engine credentials with a fallback to the shared ones so existing configurations are unaffected. appwrite-worker-deletes and appwrite-worker-migrations resolve these pools too and previously received neither family; they now get both. appwrite-worker-stats-resources inherits them from appwrite-worker. Verified on a PostgreSQL installation with no mongodb service: pointing _APP_DB_HOST_DOCUMENTSDB at a MongoDB reachable under another name makes DocumentsDB create, write and read back, which was impossible before. --- app/config/variables.php | 126 +++++++++++++++++++++++++++++++++++++++ app/init/registers.php | 12 ++-- docker-compose.yml | 60 +++++++++++++++++++ 3 files changed, 192 insertions(+), 6 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index f9247651ce6..501da3c8f7d 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -472,6 +472,132 @@ 'question' => '', 'filter' => 'password' ], + [ + 'name' => '_APP_DB_ADAPTER_DOCUMENTSDB', + 'description' => 'Engine backing DocumentsDB. Only MongoDB is supported. Default value is: mongodb.', + 'introduction' => '2.0.0', + 'default' => 'mongodb', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_HOST_DOCUMENTSDB', + 'description' => 'DocumentsDB server host name address. Requires a reachable MongoDB. Default value is: mongodb.', + 'introduction' => '2.0.0', + 'default' => 'mongodb', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_PORT_DOCUMENTSDB', + 'description' => 'DocumentsDB server TCP port. Default value is: 27017.', + 'introduction' => '2.0.0', + 'default' => '27017', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_SCHEMA_DOCUMENTSDB', + 'description' => 'DocumentsDB schema name. Falls back to _APP_DB_SCHEMA when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_USER_DOCUMENTSDB', + 'description' => 'DocumentsDB server user name. Falls back to _APP_DB_USER when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_PASS_DOCUMENTSDB', + 'description' => 'DocumentsDB server user password. Falls back to _APP_DB_PASS when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => 'password' + ], + [ + 'name' => '_APP_DB_ADAPTER_VECTORSDB', + 'description' => 'Engine backing VectorsDB. Only PostgreSQL is supported. Default value is: postgresql.', + 'introduction' => '2.0.0', + 'default' => 'postgresql', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_HOST_VECTORSDB', + 'description' => 'VectorsDB server host name address. Requires a reachable PostgreSQL. Default value is: postgresql.', + 'introduction' => '2.0.0', + 'default' => 'postgresql', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_PORT_VECTORSDB', + 'description' => 'VectorsDB server TCP port. Default value is: 5432.', + 'introduction' => '2.0.0', + 'default' => '5432', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_SCHEMA_VECTORSDB', + 'description' => 'VectorsDB schema name. Falls back to _APP_DB_SCHEMA when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_USER_VECTORSDB', + 'description' => 'VectorsDB server user name. Falls back to _APP_DB_USER when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DB_PASS_VECTORSDB', + 'description' => 'VectorsDB server user password. Falls back to _APP_DB_PASS when empty.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => 'password' + ], + [ + 'name' => '_APP_CONNECTIONS_DATABASE_DOCUMENTSDB', + 'description' => 'Full DocumentsDB connection string, overriding the _APP_DB_*_DOCUMENTSDB values. Format: db_main=mongodb://user:pass@host:port/schema.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_CONNECTIONS_DATABASE_VECTORSDB', + 'description' => 'Full VectorsDB connection string, overriding the _APP_DB_*_VECTORSDB values. Format: db_main=postgresql://user:pass@host:port/schema.', + 'introduction' => '2.0.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], ], ], [ diff --git a/app/init/registers.php b/app/init/registers.php index a1dc7279fbc..4fabf11458e 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -170,17 +170,17 @@ 'scheme' => System::getEnv('_APP_DB_ADAPTER_DOCUMENTSDB', 'mongodb'), 'host' => System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'), 'port' => System::getEnv('_APP_DB_PORT_DOCUMENTSDB', '27017'), - 'user' => System::getEnv('_APP_DB_USER', ''), - 'pass' => System::getEnv('_APP_DB_PASS', ''), - 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + 'user' => System::getEnv('_APP_DB_USER_DOCUMENTSDB', '') ?: System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS_DOCUMENTSDB', '') ?: System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA_DOCUMENTSDB', '') ?: System::getEnv('_APP_DB_SCHEMA', ''), ]); $fallbackForVectorsDB = 'db_main=' . AppwriteURL::unparse([ 'scheme' => System::getEnv('_APP_DB_ADAPTER_VECTORSDB', 'postgresql'), 'host' => System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'), 'port' => System::getEnv('_APP_DB_PORT_VECTORSDB', '5432'), - 'user' => System::getEnv('_APP_DB_USER', ''), - 'pass' => System::getEnv('_APP_DB_PASS', ''), - 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + 'user' => System::getEnv('_APP_DB_USER_VECTORSDB', '') ?: System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS_VECTORSDB', '') ?: System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA_VECTORSDB', '') ?: System::getEnv('_APP_DB_SCHEMA', ''), ]); $connections = [ diff --git a/docker-compose.yml b/docker-compose.yml index d9a95e1c705..23bc55889bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,14 @@ services: - _APP_DB_SCHEMA_VECTORSDB - _APP_DB_USER_VECTORSDB - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -315,6 +323,14 @@ services: - _APP_DB_SCHEMA_VECTORSDB - _APP_DB_USER_VECTORSDB - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_LOGGING_CONFIG - _APP_LOGGING_FORMAT - _APP_LOGGING_CONFIG_REALTIME @@ -378,6 +394,14 @@ services: - _APP_DB_SCHEMA_VECTORSDB - _APP_DB_USER_VECTORSDB - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -632,6 +656,20 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_STORAGE_DEVICE - _APP_STORAGE_S3_ACCESS_KEY - _APP_STORAGE_S3_SECRET @@ -702,6 +740,14 @@ services: - _APP_DB_SCHEMA_VECTORSDB - _APP_DB_USER_VECTORSDB - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_LOGGING_CONFIG - _APP_LOGGING_FORMAT - _APP_QUEUE_NAME @@ -1252,6 +1298,20 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB + - _APP_DB_ADAPTER_DOCUMENTSDB + - _APP_DB_HOST_DOCUMENTSDB + - _APP_DB_PORT_DOCUMENTSDB + - _APP_DB_SCHEMA_DOCUMENTSDB + - _APP_DB_USER_DOCUMENTSDB + - _APP_DB_PASS_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_DOCUMENTSDB + - _APP_CONNECTIONS_DATABASE_VECTORSDB - _APP_LOGGING_CONFIG - _APP_LOGGING_FORMAT - _APP_MIGRATIONS_FIREBASE_CLIENT_ID From c4232799336823253edc92c0ca18d28e0402bd84 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 15:10:54 +0530 Subject: [PATCH 07/37] fix: stop generating passwords for the pool credential overrides _APP_DB_PASS_DOCUMENTSDB and _APP_DB_PASS_VECTORSDB were declared with filter 'password'. The installer generates a value for any password variable that has no default, so a fresh install wrote a random secret to both. Those non-empty values then won the fallback in registers.php, while the bundled MongoDB and PostgreSQL services provision their user with _APP_DB_PASS, so both pools authenticated with a password the database never had. The generated values are also not sanitised for DSN use: the sanitiser in generatePasswordValue() only applies to names matching /^_APP_DB_.*_PASS$/, which these do not. Clear the filter so both variables install empty and fall back to _APP_DB_PASS, as the other pool overrides already do. --- app/config/variables.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 501da3c8f7d..3d87ccb6b26 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -524,7 +524,7 @@ 'default' => '', 'required' => false, 'question' => '', - 'filter' => 'password' + 'filter' => '' ], [ 'name' => '_APP_DB_ADAPTER_VECTORSDB', @@ -578,7 +578,7 @@ 'default' => '', 'required' => false, 'question' => '', - 'filter' => 'password' + 'filter' => '' ], [ 'name' => '_APP_CONNECTIONS_DATABASE_DOCUMENTSDB', From 21e50da1ad1bd689d6aed1cc660d835e7156b6e1 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 16:38:52 +0530 Subject: [PATCH 08/37] chore(deps): pin utopia-php/platform to 1.0.0-rc18 The dependency was declared through a hand-written package repository that pointed at the branch cursor/multi-job-queue-platform-f0c8 under the invented version 1.0.999. Nothing tagged contained the combined worker initialisation that app/worker.php relies on, so the build depended on a mutable branch reference that could be force-pushed or deleted. That work is merged into the monorepo main branch, and packages/platform there is identical to the pinned commit, so it has been tagged platform/1.0.0-rc18. Drop the package repository and depend on the tag. Only utopia-php/platform changes in the lock; every other package keeps its resolved version. --- composer.json | 31 ++----------------------------- composer.lock | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 37 deletions(-) diff --git a/composer.json b/composer.json index 671146f9a73..f0705a84ca7 100644 --- a/composer.json +++ b/composer.json @@ -39,32 +39,6 @@ "minimum-stability": "dev", "prefer-stable": true, "repositories": [ - { - "type": "package", - "package": { - "name": "utopia-php/platform", - "version": "1.0.999", - "source": { - "url": "https://github.com/utopia-php/monorepo.git", - "type": "git", - "reference": "cursor/multi-job-queue-platform-f0c8" - }, - "autoload": { - "psr-4": { - "Utopia\\Platform\\": "packages/platform/src/Platform" - } - }, - "require": { - "php": ">=8.5", - "ext-json": "*", - "ext-redis": "*", - "utopia-php/cli": "^0.24", - "utopia-php/http": "^2.0@RC", - "utopia-php/queue": "0.23.* || 0.24.* || ^1.0.0", - "utopia-php/servers": "^0.4" - } - } - }, { "type": "vcs", "url": "https://github.com/utopia-php/auth" @@ -115,7 +89,7 @@ "utopia-php/logger": "0.8.*", "utopia-php/messaging": "^2.1", "utopia-php/migration": "^2.0.0", - "utopia-php/platform": "^1.0@RC", + "utopia-php/platform": "1.0.0-rc18", "utopia-php/pools": "2.*", "utopia-php/span": "3.0.*", "utopia-php/queue": "^1.3.0", @@ -157,8 +131,7 @@ "ext-phpiredis": "*" }, "config": { - "platform": { - }, + "platform": {}, "audit": { "abandoned": "report" }, diff --git a/composer.lock b/composer.lock index 701e8341f3d..122f2afb683 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": "9481b6eabc4b962abea525da82d4e962", + "content-hash": "390448d7a8976e58bf2e0a1306dbc7d7", "packages": [ { "name": "adhocore/jwt", @@ -4399,11 +4399,17 @@ }, { "name": "utopia-php/platform", - "version": "1.0.999", + "version": "1.0.0-rc18", "source": { "type": "git", - "url": "https://github.com/utopia-php/monorepo.git", - "reference": "cursor/multi-job-queue-platform-f0c8" + "url": "https://github.com/utopia-php/platform.git", + "reference": "aab0b18f26cf851e8f48d6a6bb36e92ed597ce50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/platform/zipball/aab0b18f26cf851e8f48d6a6bb36e92ed597ce50", + "reference": "aab0b18f26cf851e8f48d6a6bb36e92ed597ce50", + "shasum": "" }, "require": { "ext-json": "*", @@ -4417,9 +4423,26 @@ "type": "library", "autoload": { "psr-4": { - "Utopia\\Platform\\": "packages/platform/src/Platform" + "Utopia\\Platform\\": "src/Platform" } - } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Light and Fast Platform Library", + "keywords": [ + "framework", + "php", + "platform", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/platform/issues", + "source": "https://github.com/utopia-php/platform/tree/1.0.0-rc18" + }, + "time": "2026-08-18T15:36:41+00:00" }, { "name": "utopia-php/pools", @@ -8532,8 +8555,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "utopia-php/http": 5, - "utopia-php/platform": 5 + "utopia-php/http": 5 }, "prefer-stable": true, "prefer-lowest": false, From f2ff585d321e5d3fd1f92025f47b86cedbb5998b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 16:44:53 +0530 Subject: [PATCH 09/37] feat: make the database batch limit configurable APP_LIMIT_DATABASE_BATCH was a hard-coded 100, so bulk row and document operations rejected anything larger with "Value must a valid array no longer than 100 items". On Cloud a plan raises it through databasesBatchSize, but self-hosted had no equivalent lever, leaving operators to split large loads into hundreds of sequential calls. Read the constant from _APP_LIMIT_DATABASE_BATCH, keeping 100 as the default so existing installations are unchanged, and forward it to the services that serve these endpoints. Every call site already reads $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH, so the Cloud plan still takes precedence and only the fallback becomes configurable. The value is clamped to at least 1 so a malformed setting cannot disable bulk writes entirely. It is deliberately not capped at the top: the description warns that memory use and query size grow with it, and the operator owns that trade-off for their own hardware. --- app/config/variables.php | 9 +++++++++ app/init/constants.php | 4 +++- docker-compose.yml | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/config/variables.php b/app/config/variables.php index 3d87ccb6b26..8059727c4a8 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -598,6 +598,15 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_LIMIT_DATABASE_BATCH', + 'description' => 'Maximum number of rows or documents accepted by a single bulk database operation (createRows, upsertRows, updateRows, deleteRows and their document equivalents). Raising it increases memory use and query size per request, so tune it to what your database can handle. Default value is: 100.', + 'introduction' => '2.0.0', + 'default' => '100', + 'required' => false, + 'question' => '', + 'filter' => '' + ], ], ], [ diff --git a/app/init/constants.php b/app/init/constants.php index 7b715de7a1c..137ea1a0a13 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -46,7 +46,9 @@ const APP_LIMIT_WRITE_RATE_DEFAULT = 60; // Default maximum write rate per rate period const APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT = 60; // Default maximum write rate period in seconds const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return in list API calls -const APP_LIMIT_DATABASE_BATCH = 100; // Default maximum batch size for database operations +// Default maximum batch size for database operations. Self-hosted operators can raise this +// for their own hardware; on Cloud the plan's databasesBatchSize takes precedence. +\define('APP_LIMIT_DATABASE_BATCH', \max(1, (int) System::getEnv('_APP_LIMIT_DATABASE_BATCH', 100))); const APP_LIMIT_DATABASE_TRANSACTION = 100; // Default maximum operations per transaction const APP_KEY_ACCESS = 24 * 60 * 60; // 24 hours const APP_USER_ACCESS = 24 * 60 * 60; // 24 hours diff --git a/docker-compose.yml b/docker-compose.yml index 23bc55889bb..f937a590887 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -243,6 +243,7 @@ services: - _APP_MIGRATION_HOST - _APP_GEO_SECRET - _APP_GEO_ENDPOINT + - _APP_LIMIT_DATABASE_BATCH appwrite-console: logging: driver: json-file @@ -335,6 +336,7 @@ services: - _APP_LOGGING_FORMAT - _APP_LOGGING_CONFIG_REALTIME - _APP_DATABASE_SHARED_TABLES + - _APP_LIMIT_DATABASE_BATCH - _APP_POOL_ADAPTER=swoole appwrite-worker: @@ -497,6 +499,7 @@ services: - _APP_MIGRATION_HOST - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE + - _APP_LIMIT_DATABASE_BATCH appwrite-task-scheduler: entrypoint: schedule @@ -752,6 +755,7 @@ services: - _APP_LOGGING_FORMAT - _APP_QUEUE_NAME - _APP_DATABASE_SHARED_TABLES + - _APP_LIMIT_DATABASE_BATCH appwrite-worker-builds: profiles: - separate From e92e56d2747f599ef2585cf26b679cf10d4c14c6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 16:58:22 +0530 Subject: [PATCH 10/37] feat: let operators choose which database products to deploy DocumentsDB runs only on MongoDB and VectorsDB only on PostgreSQL, but the installer provisioned a single engine, the one chosen for the platform. A default PostgreSQL installation therefore had no MongoDB, so creating a DocumentsDB database returned 201 and the first collection write failed with a bare server error, the cause visible only in the logs. A MariaDB installation lost both products the same way. Rather than always deploying every engine, ask which products the installation actually wants: - _APP_DOCUMENTSDB and _APP_VECTORSDB, both enabled by default, are asked during install, seeded from the environment for scripted runs, and preserved from the existing .env on upgrade. - The compose generator adds an engine only when the platform uses it or an enabled product needs it, so a deployment runs no database it has no use for. - A disabled product's routes throw GENERAL_SERVICE_DISABLED, for keys and privileged roles too, since the engine behind them is not deployed. Turning a product off now removes its engine instead of leaving an endpoint that fails on first write. --- app/config/variables.php | 18 ++++++++ app/controllers/shared/api.php | 13 ++++++ docker-compose.yml | 8 ++++ src/Appwrite/Docker/Compose/Generator.php | 45 ++++++++++++++++++- src/Appwrite/Platform/Tasks/Install.php | 29 +++++++++++++ tests/unit/Docker/Compose/GeneratorTest.php | 48 ++++++++++++++++++++- 6 files changed, 158 insertions(+), 3 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 8059727c4a8..c253d249b09 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -607,6 +607,24 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_DOCUMENTSDB', + 'description' => 'Enables the DocumentsDB API. It runs on MongoDB, so the installation needs a reachable MongoDB while this is enabled. Set to disabled to leave MongoDB out; the /v1/documentsdb routes then return a service disabled error. Default value is: enabled.', + 'introduction' => '2.0.0', + 'default' => 'enabled', + 'required' => false, + 'question' => 'Enable DocumentsDB? It requires MongoDB (Y/n)', + 'filter' => '' + ], + [ + 'name' => '_APP_VECTORSDB', + 'description' => 'Enables the VectorsDB API. It runs on PostgreSQL, so the installation needs a reachable PostgreSQL while this is enabled. Set to disabled to leave PostgreSQL out; the /v1/vectorsdb routes then return a service disabled error. Default value is: enabled.', + 'introduction' => '2.0.0', + 'default' => 'enabled', + 'required' => false, + 'question' => 'Enable VectorsDB? It requires PostgreSQL (Y/n)', + 'filter' => '' + ], ], ], [ diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index e71c780b7e7..a01bf90099e 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -447,6 +447,19 @@ if (! empty($method)) { $namespace = \strtolower($method->getNamespace()); + // An operator turns a database product off when its engine is not deployed, + // so the route is unavailable to everyone, keys and privileged roles included. + $productEngines = [ + 'documentsdb' => '_APP_DOCUMENTSDB', + 'vectorsdb' => '_APP_VECTORSDB', + ]; + if ( + isset($productEngines[$namespace]) + && System::getEnv($productEngines[$namespace], 'enabled') !== 'enabled' + ) { + throw new Exception(Exception::GENERAL_SERVICE_DISABLED); + } + if ( array_key_exists($namespace, $project->getAttribute('services', [])) && ! $project->getAttribute('services', [])[$namespace] diff --git a/docker-compose.yml b/docker-compose.yml index f937a590887..2a91758719c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -244,6 +244,8 @@ services: - _APP_GEO_SECRET - _APP_GEO_ENDPOINT - _APP_LIMIT_DATABASE_BATCH + - _APP_DOCUMENTSDB + - _APP_VECTORSDB appwrite-console: logging: driver: json-file @@ -337,6 +339,8 @@ services: - _APP_LOGGING_CONFIG_REALTIME - _APP_DATABASE_SHARED_TABLES - _APP_LIMIT_DATABASE_BATCH + - _APP_DOCUMENTSDB + - _APP_VECTORSDB - _APP_POOL_ADAPTER=swoole appwrite-worker: @@ -500,6 +504,8 @@ services: - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE - _APP_LIMIT_DATABASE_BATCH + - _APP_DOCUMENTSDB + - _APP_VECTORSDB appwrite-task-scheduler: entrypoint: schedule @@ -756,6 +762,8 @@ services: - _APP_QUEUE_NAME - _APP_DATABASE_SHARED_TABLES - _APP_LIMIT_DATABASE_BATCH + - _APP_DOCUMENTSDB + - _APP_VECTORSDB appwrite-worker-builds: profiles: - separate diff --git a/src/Appwrite/Docker/Compose/Generator.php b/src/Appwrite/Docker/Compose/Generator.php index 316d1c7a397..4c11118088d 100644 --- a/src/Appwrite/Docker/Compose/Generator.php +++ b/src/Appwrite/Docker/Compose/Generator.php @@ -25,6 +25,17 @@ class Generator ], ]; + /** + * Engine each specialised database product runs on. DocumentsDB only runs on + * MongoDB and VectorsDB only on PostgreSQL, so an enabled product needs its + * engine even when a different one backs the platform. A disabled product + * leaves its engine out, keeping the installation to what it actually uses. + */ + private const array PRODUCT_BACKING_SERVICES = [ + 'enableDocumentsDB' => 'mongodb', + 'enableVectorsDB' => 'postgresql', + ]; + private const array OPTIONAL_SERVICES = [ 'enableAssistant' => 'appwrite-assistant', ]; @@ -60,6 +71,8 @@ class Generator 'database' => 'postgresql', 'hostPath' => '', 'enableAssistant' => false, + 'enableDocumentsDB' => true, + 'enableVectorsDB' => true, 'topology' => 'combined', ]; @@ -161,6 +174,24 @@ private function normalizeParams(array $params): array /** * @return string[] */ + /** + * Engines the enabled database products need, on top of the platform engine. + * + * @return array + */ + private function getRequiredBackingServices(): array + { + $services = []; + + foreach (self::PRODUCT_BACKING_SERVICES as $param => $service) { + if (!empty($this->params[$param])) { + $services[] = $service; + } + } + + return $services; + } + private function getSelectableServices(): array { $services = []; @@ -180,9 +211,15 @@ private function filterServices(array $services): array { foreach (self::SELECTABLE_SERVICE_GROUPS as $param => $config) { foreach ($config['services'] as $service) { - if ($service !== $this->params[$param]) { - unset($services[$service]); + if ($service === $this->params[$param]) { + continue; + } + + if (\in_array($service, $this->getRequiredBackingServices(), true)) { + continue; } + + unset($services[$service]); } } @@ -251,6 +288,10 @@ private function filterVolumes(array $volumes): array continue; } + if (\in_array($service, $this->getRequiredBackingServices(), true)) { + continue; + } + foreach ($names as $name) { unset($volumes[$name]); } diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 3afc7c7bf09..06bb1699a4c 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -258,6 +258,33 @@ public function action( $enableAssistant = true; } + // DocumentsDB runs on MongoDB and VectorsDB on PostgreSQL. Turning one off keeps + // its engine out of the installation, so only deploy what is actually used. + $products = [ + 'enableDocumentsDB' => ['var' => '_APP_DOCUMENTSDB', 'label' => 'DocumentsDB', 'engine' => 'MongoDB'], + 'enableVectorsDB' => ['var' => '_APP_VECTORSDB', 'label' => 'VectorsDB', 'engine' => 'PostgreSQL'], + ]; + $enabledProducts = []; + foreach ($products as $param => $product) { + // On an upgrade the existing .env has already seeded the default; on a fresh + // install the environment is how a scripted run states its choice. + $current = $existingInstallation + ? ($vars[$product['var']]['default'] ?? 'enabled') !== 'disabled' + : System::getEnv($product['var'], 'enabled') !== 'disabled'; + + if ($interactive === 'Y' && Console::isInteractive()) { + $answer = Console::confirm( + "Enable {$product['label']}? It requires {$product['engine']} (Y/n)" + . ($existingInstallation ? ($current ? ' [Currently enabled]' : ' [Currently disabled]') : '') + ); + $enabledProducts[$param] = empty($answer) ? $current : \strtolower($answer) === 'y'; + } else { + $enabledProducts[$param] = $current; + } + + $vars[$product['var']]['default'] = $enabledProducts[$param] ? 'enabled' : 'disabled'; + } + if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')'); $httpPort = ($httpPort) ?: $defaultHttpPort; @@ -586,6 +613,8 @@ public function performInstallation( 'database' => $database, 'hostPath' => $this->hostPath, 'enableAssistant' => $enableAssistant, + 'enableDocumentsDB' => ($input['_APP_DOCUMENTSDB'] ?? 'enabled') !== 'disabled', + 'enableVectorsDB' => ($input['_APP_VECTORSDB'] ?? 'enabled') !== 'disabled', 'topology' => $this->topology, ]); diff --git a/tests/unit/Docker/Compose/GeneratorTest.php b/tests/unit/Docker/Compose/GeneratorTest.php index 8b0f259aafb..b07151999e2 100644 --- a/tests/unit/Docker/Compose/GeneratorTest.php +++ b/tests/unit/Docker/Compose/GeneratorTest.php @@ -25,6 +25,8 @@ public function testSelectsDatabaseService(): void $compose = $this->render([ 'database' => 'mariadb', 'enableAssistant' => false, + 'enableDocumentsDB' => false, + 'enableVectorsDB' => false, ]); $this->assertArrayHasKey('mariadb', $compose['services']); @@ -37,7 +39,10 @@ public function testSelectsDatabaseService(): void public function testDefaultsToPostgreSQL(): void { - $compose = $this->render(); + $compose = $this->render([ + 'enableDocumentsDB' => false, + 'enableVectorsDB' => false, + ]); $this->assertArrayHasKey('postgresql', $compose['services']); $this->assertArrayNotHasKey('mongodb', $compose['services']); @@ -47,6 +52,47 @@ public function testDefaultsToPostgreSQL(): void $this->assertArrayNotHasKey('appwrite-mariadb', $compose['volumes']); } + public function testEnabledProductsAddTheirEngine(): void + { + $compose = $this->render([ + 'database' => 'mariadb', + 'enableDocumentsDB' => true, + 'enableVectorsDB' => true, + ]); + + $this->assertArrayHasKey('mariadb', $compose['services']); + $this->assertArrayHasKey('mongodb', $compose['services'], 'DocumentsDB runs on MongoDB'); + $this->assertArrayHasKey('postgresql', $compose['services'], 'VectorsDB runs on PostgreSQL'); + $this->assertArrayHasKey('appwrite-mongodb', $compose['volumes']); + $this->assertArrayHasKey('appwrite-postgresql', $compose['volumes']); + } + + public function testDisabledProductDropsItsEngine(): void + { + $compose = $this->render([ + 'database' => 'postgresql', + 'enableDocumentsDB' => false, + 'enableVectorsDB' => true, + ]); + + $this->assertArrayNotHasKey('mongodb', $compose['services']); + $this->assertArrayNotHasKey('appwrite-mongodb', $compose['volumes']); + $this->assertArrayHasKey('postgresql', $compose['services'], 'still the platform engine'); + } + + public function testPlatformEngineSurvivesItsProductBeingDisabled(): void + { + $compose = $this->render([ + 'database' => 'mongodb', + 'enableDocumentsDB' => false, + 'enableVectorsDB' => false, + ]); + + $this->assertArrayHasKey('mongodb', $compose['services'], 'selected as the platform engine'); + $this->assertArrayHasKey('appwrite-mongodb', $compose['volumes']); + $this->assertArrayNotHasKey('postgresql', $compose['services']); + } + public function testTogglesAssistantService(): void { $disabled = $this->render([ From de7f2585e9cc22f856d60d1220894ebde0c6913f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 17:29:14 +0530 Subject: [PATCH 11/37] test: cover a product reusing the platform engine A product whose engine already backs the platform must not add a second one: MongoDB with DocumentsDB enabled is a single-engine deployment. The generator does this, but nothing asserted it, so a future change to how backing engines are resolved could start provisioning a duplicate without failing a test. --- tests/unit/Docker/Compose/GeneratorTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/Docker/Compose/GeneratorTest.php b/tests/unit/Docker/Compose/GeneratorTest.php index b07151999e2..190256975fb 100644 --- a/tests/unit/Docker/Compose/GeneratorTest.php +++ b/tests/unit/Docker/Compose/GeneratorTest.php @@ -80,6 +80,23 @@ public function testDisabledProductDropsItsEngine(): void $this->assertArrayHasKey('postgresql', $compose['services'], 'still the platform engine'); } + public function testProductReusesThePlatformEngine(): void + { + $compose = $this->render([ + 'database' => 'mongodb', + 'enableDocumentsDB' => true, + 'enableVectorsDB' => false, + ]); + + $engines = \array_intersect( + ['postgresql', 'mariadb', 'mongodb'], + \array_keys($compose['services']) + ); + + $this->assertSame(['mongodb'], \array_values($engines), 'DocumentsDB reuses MongoDB rather than adding a second engine'); + $this->assertArrayNotHasKey('postgresql', $compose['services']); + } + public function testPlatformEngineSurvivesItsProductBeingDisabled(): void { $compose = $this->render([ From 87c3467b238509141388a0e99fe3f87458c3c6f8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 17:36:46 +0530 Subject: [PATCH 12/37] feat: offer the database products in the web installer The CLI install asks whether to deploy DocumentsDB and VectorsDB, but the web installer had no equivalent, so anyone using it always got both engines with no way to opt out. Add a toggle for each alongside the database selector, carry the choice through the wizard state into the install request, and show both on the review step so the deployment is confirmed before it starts. The server maps them onto _APP_DOCUMENTSDB and _APP_VECTORSDB, which the compose generator already reads, so the engine set follows the same rules as the CLI path. Defaults come from the variable defaults, keeping both enabled unless an existing .env says otherwise. --- app/views/install/installer.phtml | 4 +++ .../install/installer/js/modules/progress.js | 2 ++ .../install/installer/js/modules/state.js | 2 ++ app/views/install/installer/js/modules/ui.js | 12 ++++++++ app/views/install/installer/js/steps.js | 14 ++++++++++ .../installer/templates/steps/step-1.phtml | 28 +++++++++++++++++++ .../installer/templates/steps/step-4.phtml | 8 ++++++ .../Installer/Http/Installer/Install.php | 6 ++++ .../Installer/Http/Installer/View.php | 2 ++ 9 files changed, 78 insertions(+) diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index ae858912f65..7d919472b85 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -9,6 +9,8 @@ $defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost'; $defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain; $defaultEmailCertificates ??= ''; $defaultForceHttps ??= ($vars['_APP_OPTIONS_FORCE_HTTPS']['default'] ?? 'disabled') === 'enabled'; +$defaultDocumentsDB ??= ($vars['_APP_DOCUMENTSDB']['default'] ?? 'enabled') !== 'disabled'; +$defaultVectorsDB ??= ($vars['_APP_VECTORSDB']['default'] ?? 'enabled') !== 'disabled'; $defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'postgresql'; $enabledDatabases ??= ['postgresql', 'mariadb', 'mongodb']; $isLocalInstall ??= false; @@ -65,6 +67,8 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() data-default-app-domain="" data-default-email-certificates="" data-default-force-https="" + data-default-documentsdb="" + data-default-vectorsdb="" data-default-secret-key="" data-default-assistant-openai-key="" data-default-database="" diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index c1e59add0e4..6f0fb31e54f 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -369,6 +369,8 @@ httpsPort: normalizedHttpsPort, database: formState?.database || 'postgresql', topology: formState?.topology || 'combined', + documentsDB: formState?.documentsDB !== false, + vectorsDB: formState?.vectorsDB !== false, appDomain: normalizedDomain, emailCertificates: normalizedEmail, forceHttps: formState?.forceHttps === true, diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 408c3d28e9b..7e9a25b8c26 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -20,6 +20,8 @@ opensslKey: null, assistantOpenAIKey: null, topology: 'combined', + documentsDB: true, + vectorsDB: true, accountEmail: null, accountPassword: null }; diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index 1ef47f69d8f..b1f135c98ce 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -265,6 +265,18 @@ httpsBadge.classList.add(forceHttps ? 'badge-success' : 'badge-neutral'); } + const productBadges = [ + ['[data-review-documentsdb-badge]', formState?.documentsDB !== false], + ['[data-review-vectorsdb-badge]', formState?.vectorsDB !== false], + ]; + productBadges.forEach(([selector, enabled]) => { + const badge = root.querySelector(selector); + if (!badge) return; + badge.textContent = enabled ? 'Enabled' : 'Disabled'; + badge.classList.remove('badge-success', 'badge-neutral'); + badge.classList.add(enabled ? 'badge-success' : 'badge-neutral'); + }); + const assistantBadge = root.querySelector('[data-review-assistant-badge]'); if (assistantBadge) { const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim()); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 00176bce5c2..8def94b17f5 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -122,6 +122,8 @@ State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value); State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value); State.setStateIfEmpty?.('forceHttps', root.querySelector('#force-https')?.checked); + State.setStateIfEmpty?.('documentsDB', root.querySelector('#documentsdb')?.checked); + State.setStateIfEmpty?.('vectorsDB', root.querySelector('#vectorsdb')?.checked); State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); }; @@ -143,6 +145,16 @@ forceHttps.checked = formState.forceHttps; } + const documentsDB = root.querySelector('#documentsdb'); + if (documentsDB && typeof formState.documentsDB === 'boolean') { + documentsDB.checked = formState.documentsDB; + } + + const vectorsDB = root.querySelector('#vectorsdb'); + if (vectorsDB && typeof formState.vectorsDB === 'boolean') { + vectorsDB.checked = formState.vectorsDB; + } + const assistantKey = root.querySelector('#assistant-openai-key'); if (assistantKey && formState.assistantOpenAIKey) { assistantKey.value = formState.assistantOpenAIKey; @@ -212,6 +224,8 @@ bindInputToState(httpsPort, 'httpsPort'); bindInputToState(sslEmail, 'emailCertificates'); bindCheckboxToState(forceHttps, 'forceHttps'); + bindCheckboxToState(root.querySelector('#documentsdb'), 'documentsDB'); + bindCheckboxToState(root.querySelector('#vectorsdb'), 'vectorsDB'); bindInputToState(assistantKey, 'assistantOpenAIKey'); bindErrorClear?.(hostname); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index f157dad933f..99159303046 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -10,6 +10,8 @@ $defaultEmailCertificates ??= ''; $defaultForceHttps ??= false; $defaultAssistantOpenAIKey ??= ''; $defaultDatabase ??= 'postgresql'; +$defaultDocumentsDB ??= true; +$defaultVectorsDB ??= true; $enabledDatabases ??= ['postgresql', 'mariadb', 'mongodb']; $selectedDatabase = $lockedDatabase ?: $defaultDatabase; $isDatabaseLocked = !empty($lockedDatabase); @@ -107,6 +109,32 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey, + + + +
+
+ Enabled +
DocumentsDB
+
+
+ Enabled +
VectorsDB
+
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 590a62a75ce..5ec069a7ba3 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -43,6 +43,8 @@ public function __construct() ->param('accountPassword', '', new Password(allowEmpty: true), 'Account password', true) ->param('database', '', new WhiteList(['postgresql', 'mariadb', 'mongodb']), 'Database adapter', true) ->param('topology', 'combined', new WhiteList(['combined', 'separate']), 'Worker and scheduler topology', true) + ->param('documentsDB', true, new \Utopia\Validator\Boolean(true), 'Deploy DocumentsDB and the MongoDB it runs on', true) + ->param('vectorsDB', true, new \Utopia\Validator\Boolean(true), 'Deploy VectorsDB and the PostgreSQL it runs on', true) ->param('installId', '', new Text(64, 0), 'Installation ID', true) ->param('retryStep', null, new Nullable(new WhiteList([ Server::STEP_CONFIG_FILES, @@ -74,6 +76,8 @@ public function action( string $accountPassword, string $database, string $topology, + bool $documentsDB, + bool $vectorsDB, string $installId, ?string $retryStep, bool $migrate, @@ -233,6 +237,8 @@ public function action( '_APP_EMAIL_CERTIFICATES' => $emailCertificates, '_APP_DB_ADAPTER' => $lockedDatabase ?? ($database ?: 'postgresql'), '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, + '_APP_DOCUMENTSDB' => $documentsDB ? 'enabled' : 'disabled', + '_APP_VECTORSDB' => $vectorsDB ? 'enabled' : 'disabled', ]; $previousHadError = is_array($existing) && isset($existing['error']); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php index 0ae21d402d8..1bacc6e735f 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/View.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -50,6 +50,8 @@ public function action(int $step, ?string $partial, Request $request, Response $ $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; $defaultForceHttps = ($vars['_APP_OPTIONS_FORCE_HTTPS']['default'] ?? 'disabled') === 'enabled'; + $defaultDocumentsDB = ($vars['_APP_DOCUMENTSDB']['default'] ?? 'enabled') !== 'disabled'; + $defaultVectorsDB = ($vars['_APP_VECTORSDB']['default'] ?? 'enabled') !== 'disabled'; if ($isLocalInstall && empty($defaultEmailCertificates)) { $defaultEmailCertificates = 'walterobrien@example.com'; } From 90b4058db1e1a3c678f3542ce1299ed5e483f0ce Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 27 Aug 2026 17:51:14 +0530 Subject: [PATCH 13/37] fix: install MongoDB support files whenever DocumentsDB needs them Two problems with the database product toggles, both from review. The MongoDB support files were copied only when MongoDB backed the platform. With PostgreSQL or MariaDB selected and DocumentsDB enabled the generated compose still declares the mongodb service and bind-mounts mongo-init.js and mongo-entrypoint.sh, but neither file was written, so Docker created directories in their place and the container restarted in a loop. Copy them whenever MongoDB is required by the platform or by DocumentsDB. The web installer seeded both products as true in its form state, and setStateIfEmpty only fills empty values, so a rendered "disabled" default was ignored and applyStep1State re-checked the box. Upgrading an installation that had turned a product off silently submitted it as enabled. Seed both as null and hydrate them from the rendered defaults, as the other fields already do. Also drops an orphaned PHPDoc block left above getRequiredBackingServices() and renames $productEngines to $productToggles, since it maps a namespace to its environment variable rather than to an engine. --- app/controllers/shared/api.php | 6 +++--- app/views/install/installer/js/modules/state.js | 6 ++++-- src/Appwrite/Docker/Compose/Generator.php | 3 --- src/Appwrite/Platform/Tasks/Install.php | 7 ++++++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index a01bf90099e..9a264c2909b 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -449,13 +449,13 @@ // An operator turns a database product off when its engine is not deployed, // so the route is unavailable to everyone, keys and privileged roles included. - $productEngines = [ + $productToggles = [ 'documentsdb' => '_APP_DOCUMENTSDB', 'vectorsdb' => '_APP_VECTORSDB', ]; if ( - isset($productEngines[$namespace]) - && System::getEnv($productEngines[$namespace], 'enabled') !== 'enabled' + isset($productToggles[$namespace]) + && System::getEnv($productToggles[$namespace], 'enabled') !== 'enabled' ) { throw new Exception(Exception::GENERAL_SERVICE_DISABLED); } diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 7e9a25b8c26..a1b878d7a60 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -20,8 +20,8 @@ opensslKey: null, assistantOpenAIKey: null, topology: 'combined', - documentsDB: true, - vectorsDB: true, + documentsDB: null, + vectorsDB: null, accountEmail: null, accountPassword: null }; @@ -49,6 +49,8 @@ setStateIfEmpty('httpsPort', data.defaultHttpsPort); setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); setStateIfEmpty('forceHttps', data.defaultForceHttps === 'true'); + setStateIfEmpty('documentsDB', data.defaultDocumentsdb !== 'false'); + setStateIfEmpty('vectorsDB', data.defaultVectorsdb !== 'false'); setStateIfEmpty('opensslKey', data.defaultSecretKey); setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); if (data.lockedDatabase) { diff --git a/src/Appwrite/Docker/Compose/Generator.php b/src/Appwrite/Docker/Compose/Generator.php index 4c11118088d..af174e7684b 100644 --- a/src/Appwrite/Docker/Compose/Generator.php +++ b/src/Appwrite/Docker/Compose/Generator.php @@ -171,9 +171,6 @@ private function normalizeParams(array $params): array return $params; } - /** - * @return string[] - */ /** * Engines the enabled database products need, on top of the platform engine. * diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 06bb1699a4c..93b831ba68e 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -672,7 +672,12 @@ public function performInstallation( $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages); } - if ($database === 'mongodb' && !$useExistingConfig && $startIndex <= 1) { + // DocumentsDB runs on MongoDB, so the service, and its bind-mounted support + // files, can be present even when another engine backs the platform. + $needsMongo = $database === 'mongodb' + || ($input['_APP_DOCUMENTSDB'] ?? 'enabled') !== 'disabled'; + + if ($needsMongo && !$useExistingConfig && $startIndex <= 1) { $this->copyMongoFilesIfNeeded(); } From e1b92c1dc4303fab85a0ed02099cf81736b55e9f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 27 Aug 2026 18:39:20 +0530 Subject: [PATCH 14/37] feat(console): allow Google sign-in to the console project Adds a Google slot to the console project's oAuthProviders, following the same {provider}Enabled / {provider}Appid / {provider}Secret contract that app/controllers/api/account.php reads at sign-in. Without it createOAuth2Session('google') fails the Enabled check with project_provider_disabled, which is what the console currently returns even though the UI ships a Google button. --- app/config/console.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/config/console.php b/app/config/console.php index 262dbc41c45..bc4c6f0b492 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -64,6 +64,9 @@ 'bitbucketEnabled' => true, 'bitbucketSecret' => System::getEnv('_APP_CONSOLE_BITBUCKET_SECRET', ''), 'bitbucketAppid' => System::getEnv('_APP_CONSOLE_BITBUCKET_APP_ID', ''), + 'googleEnabled' => true, + 'googleSecret' => System::getEnv('_APP_CONSOLE_GOOGLE_SECRET', ''), + 'googleAppid' => System::getEnv('_APP_CONSOLE_GOOGLE_APP_ID', ''), ], 'smtpBaseTemplate' => APP_BRANDED_EMAIL_BASE_TEMPLATE, ]; From f2075345bf4be76ee11679042188f0c756e06fb0 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:26:30 +0100 Subject: [PATCH 15/37] fix(realtime): drop dead fan-out grouping, meter outbound bytes The fan-out grouped receivers by their matched subscription IDs so one encoded frame could serve many connections. Those IDs are ID::unique() per subscription, so no two connections can ever share one and the key never collapsed -- it built one group of one, every time. Replaced with a direct loop over $receivers, which says the same thing in fewer lines. Behaviour is identical: frames, message totals and byte totals are byte-for-byte equal across 1..1000 connections and 1..3 subscriptions per connection. Also meter outbound bytes. Fan-out cost is driven by payload size times fan-out width, not by message count, and nothing exposed that: during OnCall #1175 three pods stepped +18/+78/+226Mi at a message rate of 21/s, below a 74/s peek 70 minutes earlier that cost nothing. The quantity that actually moved was invisible. $outboundBytes was already computed for usage accounting; this puts it on a counter too. Co-Authored-By: Claude Opus 5 (1M context) --- app/realtime.php | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 2c5894bdf24..c58c2a6e214 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -545,6 +545,10 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $register->set('telemetry.connectionCounter', fn () => $telemetry->createUpDownCounter('realtime.server.open_connections')); $register->set('telemetry.connectionCreatedCounter', fn () => $telemetry->createCounter('realtime.server.connection.created')); $register->set('telemetry.messageSentCounter', fn () => $telemetry->createCounter('realtime.server.message.sent')); + // Fan-out cost is driven by bytes, not message count: one large document to many + // subscribers allocates far more than many small ones. Without this, a burst that + // moves hundreds of MB is invisible next to a flat message rate. + $register->set('telemetry.outboundBytesCounter', fn () => $telemetry->createCounter('realtime.server.outbound_bytes', 'By')); $register->set('telemetry.deliveryDelayHistogram', fn () => $telemetry->createHistogram( name: 'realtime.server.delivery_delay', unit: 'ms', @@ -639,18 +643,11 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $subscribers = $realtime->getSubscribers($event); - $groups = []; foreach ($subscribers as $id => $matched) { - $key = implode(',', array_keys($matched)); - $groups[$key]['ids'][] = $id; - $groups[$key]['subscriptions'] = array_keys($matched); - } - - foreach ($groups as $group) { $data = $event['data']; - $data['subscriptions'] = $group['subscriptions']; + $data['subscriptions'] = array_keys($matched); - $server->send($group['ids'], json_encode([ + $server->send([$id], json_encode([ 'type' => 'event', 'data' => $data ])); @@ -788,35 +785,32 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document Console::log("[Debug][Worker {$workerId}] Event: " . $payload); } - // Group connections by matched subscription IDs for batch sending - $groups = []; - foreach ($receivers as $id => $matched) { - $key = implode(',', array_keys($matched)); - $groups[$key]['ids'][] = $id; - $groups[$key]['subscriptions'] = array_keys($matched); - } - $total = 0; $outboundBytes = 0; - foreach ($groups as $group) { + // One frame per connection. `subscriptions` carries that connection's + // matched subscription IDs, and those are ID::unique() per connection + // (see the subscribe handler), so no two connections can ever share a + // frame. The grouping this replaces keyed on exactly those IDs, so it + // never collapsed -- it always built one group of one. + foreach ($receivers as $id => $matched) { $data = $event['data']; - $data['subscriptions'] = $group['subscriptions']; + $data['subscriptions'] = array_keys($matched); $payloadJson = json_encode([ 'type' => 'event', 'data' => $data ]); - $server->send($group['ids'], $payloadJson); + $server->send([$id], $payloadJson); - $count = count($group['ids']); - $total += $count; - $outboundBytes += strlen($payloadJson) * $count; + $total++; + $outboundBytes += strlen($payloadJson); } if ($total > 0) { $register->get('telemetry.messageSentCounter')->add($total); + $register->get('telemetry.outboundBytesCounter')->add($outboundBytes); $stats->incr($event['project'], 'messages', $total); $updatedAt = $event['data']['payload']['$updatedAt'] ?? null; if (\is_string($updatedAt)) { From 190d9093375fd47d06ba485dd8290141050d7c45 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 28 Aug 2026 19:44:42 +0530 Subject: [PATCH 16/37] Merge pull request #13399 from appwrite/fix/migrate-builds-volume-on-upgrade refactor(install): versioned infrastructure migrations, carrying build artifacts across the 2.0 volume rename --- .../Migration/Infrastructure/Migration.php | 141 +++++++++++++++++ .../Migration/Infrastructure/Version/V1.php | 149 ++++++++++++++++++ src/Appwrite/Platform/Tasks/Install.php | 85 +++++++++- 3 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/Appwrite/Migration/Infrastructure/Migration.php create mode 100644 src/Appwrite/Migration/Infrastructure/Version/V1.php diff --git a/src/Appwrite/Migration/Infrastructure/Migration.php b/src/Appwrite/Migration/Infrastructure/Migration.php new file mode 100644 index 00000000000..01cd1b0a169 --- /dev/null +++ b/src/Appwrite/Migration/Infrastructure/Migration.php @@ -0,0 +1,141 @@ + + */ + public static array $versions = [ + '2.0.0' => 'V1', + ]; + + /** + * Resolved environment for the installation being upgraded. + * + * @var array + */ + protected array $env = []; + + protected string $path = ''; + + /** + * Migrations to run when moving between two releases, oldest first. + * + * A release is crossed when it is newer than the installed version and no newer than + * the one being installed, so re-running an upgrade replays nothing already applied. + * + * @return array + */ + final public static function between(string $from, string $to): array + { + $from = self::normalize($from); + $to = self::normalize($to); + + $versions = self::$versions; + \uksort($versions, static fn (string $a, string $b): int => \version_compare($a, $b)); + + $migrations = []; + foreach ($versions as $version => $class) { + if (\version_compare($from, $version, '>=') || \version_compare($to, $version, '<')) { + continue; + } + + $name = __NAMESPACE__ . '\\Version\\' . $class; + if (!\class_exists($name)) { + Console::warning('Skipping unknown infrastructure migration "' . $class . '".'); + continue; + } + + $migrations[] = new $name(); + } + + return $migrations; + } + + /** + * Reduces a version to the release it belongs to. + * + * A pre-release carries the same infrastructure as the release it leads to, so + * 2.0.0-rc.1 has to count as 2.0.0 rather than sorting below it. Anything that is not + * a version at all -- "latest", "local", a branch name -- is newer than every release, + * so it runs everything the installed version has not seen. + */ + private static function normalize(string $version): string + { + if (!\preg_match('/^\d+(\.\d+)*/', $version, $matches)) { + return \PHP_INT_MAX . '.0.0'; + } + + return $matches[0]; + } + + /** + * @param array $env + */ + final public function setContext(array $env, string $path): static + { + $this->env = $env; + $this->path = $path; + + return $this; + } + + /** + * The release these changes belong to, for the upgrade log. + */ + abstract public function getName(): string; + + /** + * The changes this release makes, described for the upgrade log, in the order they + * must be applied. + * + * Each must be safe to run again: an upgrade can be retried, and a partly applied + * change must not be made worse by a second pass. + * + * @return array + */ + abstract protected function changes(): array; + + /** + * Applies every change in the release, reporting whether all of them landed. + * + * A change that fails does not stop the ones after it. They are independent -- a + * volume that could not be copied says nothing about a file that has to move -- and + * skipping the rest would leave more of the installation behind than the one failure + * warrants. The caller is told, so it can keep whatever it needs to try again. + */ + final public function execute(): bool + { + $applied = true; + + foreach ($this->changes() as $description => $change) { + Console::info($this->getName() . ': ' . $description); + + try { + $change(); + } catch (\Throwable $error) { + $applied = false; + Console::warning('"' . $description . '" failed: ' . $error->getMessage()); + } + } + + return $applied; + } +} diff --git a/src/Appwrite/Migration/Infrastructure/Version/V1.php b/src/Appwrite/Migration/Infrastructure/Version/V1.php new file mode 100644 index 00000000000..d1c68e68fe4 --- /dev/null +++ b/src/Appwrite/Migration/Infrastructure/Version/V1.php @@ -0,0 +1,149 @@ + $this->carryBuildArtifacts(...), + ]; + } + + /** + * Migrates the old builds volume to the one the orchestrator is pinned to: + * _appwrite-builds to appwrite-builds. + * + * Compose prefixed the volume with the project until 2.0, which names it explicitly so + * jobs-service build containers, created outside the Compose project, can mount it by a + * fixed name. Without this the upgrade mounts a new, empty volume and every existing + * artifact is left behind. + */ + private function carryBuildArtifacts(): void + { + $target = (string) ($this->env['_APP_BUILDS_VOLUME'] ?? '') ?: System::getEnv('_APP_BUILDS_VOLUME', 'appwrite-builds'); + $image = (string) ($this->env['_APP_IMAGE'] ?? 'appwrite/appwrite') . ':' . (string) ($this->env['_APP_VERSION'] ?? 'latest'); + + // Counted with the Appwrite image, which the upgrade has already pulled, so reading + // a volume never depends on fetching another one. Null when the volume could not be + // read at all, which must not be mistaken for an empty one: that would read as + // nothing to migrate and strand the artifacts in silence. + $files = static function (string $volume) use ($image): ?int { + $stdout = ''; + $stderr = ''; + $exit = Console::execute( + 'docker run --rm -v ' . \escapeshellarg($volume . ':/v:ro') . ' ' . \escapeshellarg($image) + . ' sh -c ' . \escapeshellarg('find /v -type f | wc -l'), + '', + $stdout, + $stderr + ); + + return $exit === 0 ? (int) \trim($stdout) : null; + }; + + $stdout = ''; + $stderr = ''; + $exit = Console::execute('docker volume ls --format ' . \escapeshellarg('{{.Name}}'), '', $stdout, $stderr); + + if ($exit !== 0) { + throw new \RuntimeException('could not list Docker volumes: ' . \trim($stderr ?: $stdout)); + } + + $volumes = \array_filter(\array_map('trim', \explode("\n", $stdout))); + + $legacy = []; + $unreadable = false; + foreach ($volumes as $volume) { + if ($volume === $target || !\str_ends_with($volume, '_' . $target)) { + continue; + } + + $count = $files($volume); + + // Told apart from an empty volume, and raised rather than warned about: the copy + // can still be made once whatever stopped the read is fixed. + if ($count === null) { + $unreadable = true; + break; + } + + if ($count > 0) { + $legacy[] = $volume; + } + } + + if ($unreadable) { + throw new \RuntimeException('could not read the contents of the build volumes'); + } + + if ($legacy === []) { + return; + } + + if (\count($legacy) > 1) { + Console::warning( + 'Found more than one previous build volume (' . \implode(', ', $legacy) . '), so none was copied.' + . ' Copy the correct one onto "' . $target . '" before using existing deployments.' + ); + return; + } + + $source = $legacy[0]; + Console::info('Copying build artifacts from "' . $source . '" to "' . $target . '"...'); + + // Only what is not already there in full is copied, so a copy that died half way is + // carried on rather than started again. Matching on size rather than existence is + // what makes that safe: a copy killed mid-file leaves a short one behind, which has + // to be recognised as unfinished and written again. What is still not intact + // afterwards is listed, because a copy can stop early -- a full disk, a killed + // container -- while everything it did run reports success. + $stdout = ''; + $stderr = ''; + $exit = Console::execute( + 'docker run --rm -v ' . \escapeshellarg($source . ':/from:ro') . ' -v ' . \escapeshellarg($target . ':/to') + . ' ' . \escapeshellarg($image) . ' sh -c ' . \escapeshellarg( + 'cd /from && find . -type f | while read -r file; do' + . ' size=$(stat -c %s "$file");' + . ' [ "$(stat -c %s "/to/$file" 2>/dev/null)" = "$size" ] ||' + . ' { mkdir -p "/to/$(dirname "$file")" && cp -a "$file" "/to/$file"; };' + . ' [ "$(stat -c %s "/to/$file" 2>/dev/null)" = "$size" ] || echo "$file"; done' + ), + '', + $stdout, + $stderr + ); + + $missing = \array_filter(\array_map('trim', \explode("\n", $stdout))); + + // A container that never ran lists nothing missing, which would otherwise read the + // same as a copy that left nothing behind. + if ($exit !== 0) { + throw new \RuntimeException( + 'could not copy build artifacts from "' . $source . '": ' . (\trim($stderr) ?: 'docker exited with ' . $exit) + ); + } + + if ($missing !== []) { + throw new \RuntimeException( + \count($missing) . ' build file(s) could not be copied from "' . $source . '"' + . ($stderr === '' ? '' : ': ' . \trim($stderr)) + ); + } + + Console::success('Copied ' . $files($target) . ' build file(s). "' . $source . '" was left in place.'); + } +} diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 93b831ba68e..74ba360ea8d 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -5,6 +5,7 @@ use Appwrite\Docker\Compose; use Appwrite\Docker\Compose\Generator; use Appwrite\Docker\Env; +use Appwrite\Migration\Infrastructure\Migration as InfrastructureMigration; use Appwrite\Platform\Installer\Runtime\State; use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; @@ -379,7 +380,7 @@ protected function startWebServer(string $defaultHttpPort, string $defaultHttpsP $enabledDatabases[] = $lockedDatabase; } - $this->setInstallerConfig([ + $config = [ 'defaultHttpPort' => $defaultHttpPort, 'defaultHttpsPort' => $defaultHttpsPort, 'organization' => $organization, @@ -392,7 +393,16 @@ protected function startWebServer(string $defaultHttpPort, string $defaultHttpsP 'enabledDatabases' => $enabledDatabases, 'isLocal' => $this->isLocalInstall(), 'hostPath' => $this->hostPath ?: null, - ]); + ]; + + // Restarting the installer rewrites this config, which would drop the version an + // interrupted upgrade started from -- the one record left once the compose file and + // .env read as the version being installed. + if (isset($installerConfig['upgradeFrom'])) { + $config['upgradeFrom'] = $installerConfig['upgradeFrom']; + } + + $this->setInstallerConfig($config); // Start Swoole-based installer server in background // Redirect stdout/stderr to a log file so exec() returns immediately @@ -529,6 +539,7 @@ private function setInstallerConfig(array $config): void return; } + $this->installerConfig = $config; putenv('APPWRITE_INSTALLER_CONFIG=' . $json); $path = InstallerServer::INSTALLER_CONFIG_FILE; if (@file_put_contents($path, $json) === false) { @@ -582,6 +593,42 @@ public function performInstallation( $version = 'local'; } + // Read before the compose file and .env are rewritten below, which would replace + // the version being upgraded from with the one being upgraded to. The compose file + // is authoritative -- it is what the running containers were started from -- and + // .env covers installations whose compose file is missing or unreadable. + $installedVersion = ''; + if ($isUpgrade) { + $existingCompose = $this->readExistingCompose(); + + if ($existingCompose !== '') { + try { + $installedVersion = (new Compose($existingCompose))->getService('appwrite')->getImageVersion(); + } catch (\Throwable) { + // No appwrite service to read a tag from; .env below covers it. + } + } + + if ($installedVersion === '') { + $existingEnv = @\file_get_contents($this->path . '/' . $this->getEnvFileName()); + $installedVersion = $existingEnv === false + ? '' + : (string) ((new Env($existingEnv))->list()['_APP_VERSION'] ?? ''); + } + + // An attempt that was interrupted after rewriting those files leaves both + // reading as the version being installed, which would look like an upgrade with + // nothing to cross. Remember the version first, so a resumed attempt still knows + // where it started; the infrastructure changes below forget it once applied. + $installerConfig = $this->readInstallerConfig(); + + if ($installedVersion === '' || $installedVersion === $version) { + $installedVersion = (string) ($installerConfig['upgradeFrom'] ?? ''); + } elseif (($installerConfig['upgradeFrom'] ?? null) !== $installedVersion) { + $this->setInstallerConfig(\array_merge($installerConfig, ['upgradeFrom' => $installedVersion])); + } + } + if (!$isLocalInstall && $this->hostPath === '') { $this->hostPath = $this->detectInstallerHostPath($this->path) ?? ''; } @@ -681,11 +728,45 @@ public function performInstallation( $this->copyMongoFilesIfNeeded(); } + // Changes to what the containers run on, rather than to what is inside the + // database. The new compose file and .env are written by now, and a volume or a + // mount can only be moved while nothing is attached to it -- so this has to + // happen before anything starts, including a start the operator does by hand + // after --no-start. Not bounded by the step being resumed from: a version is + // only still here because the changes for it have not all landed yet, whichever + // step the attempt that left it got to. + if ($isUpgrade && $installedVersion !== '') { + $applied = true; + + foreach (InfrastructureMigration::between($installedVersion, $version) as $migration) { + Console::info('Applying infrastructure changes from ' . $migration->getName() . '...'); + + try { + $applied = $migration->setContext($input, $this->path)->execute() && $applied; + } catch (\Throwable $error) { + // The containers still start: what could not be changed is reported + // rather than taking the upgrade down with it. + $applied = false; + Console::warning('Infrastructure changes from ' . $migration->getName() . ' failed: ' . $error->getMessage()); + } + } + + // Forgotten only once everything landed, so anything that failed is tried + // again next time; from here a later upgrade reads its starting version off + // the compose file rather than replaying this one. + if ($applied) { + $installerConfig = $this->readInstallerConfig(); + unset($installerConfig['upgradeFrom']); + $this->setInstallerConfig($installerConfig); + } + } + if (!$noStart) { $shouldStartContainers = $startIndex <= 2; if ($shouldStartContainers) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); if (!$isUpgrade) { From d989229279fb7601b63564a29c42932fa66f9154 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:50:07 +0100 Subject: [PATCH 17/37] perf(realtime): single Swoole worker, encode the document once per event Realtime defaults to one Swoole worker per container instead of CPU count x _APP_WORKER_PER_CORE (6). Measured on a single worker holding ~1200 websocket connections: 0.06-0.35 cores, and ~84% of each additional worker's footprint is fixed per-process overhead rather than connection state (~37MiB fixed vs ~71KiB per connection). Extra workers also each subscribe to the firehose, so every worker json_decodes every event, and they split the accept distribution into a second balancing layer invisible to the deployment (connections per worker ranged 51-170 inside one container). Concurrency belongs to the deployment. _APP_WORKERS_NUM still overrides. Also serialise the event document once per event rather than once per subscriber. `subscriptions` is the only part of the frame that varies per connection and it is small, so the rest is encoded once and reused. A 199Hz profile taken during a live fan-out burst put this loop's json_encode at 26% of on-CPU work, against 2.8% at rest, with 257 of 285 samples on the pubsub callback's encode. Frames are unchanged apart from key order: 36 comparisons across unicode escaping, slashes, empty data, nested empties, a pre-set `subscriptions` key and a blob containing quotes and backslashes all decode identically. Co-Authored-By: Claude Opus 5 (1M context) --- app/config/variables.php | 2 +- app/realtime.php | 33 +++++++++++++++++++++------------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 195ea7da47c..0ec0d0e3dbe 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -351,7 +351,7 @@ ], [ 'name' => '_APP_WORKER_PER_CORE', - 'description' => 'Internal Worker per core for the API, Realtime and Executor containers. Can be configured to optimize performance.', + 'description' => 'Internal Worker per core for the API and Executor containers. Can be configured to optimize performance. Realtime ignores this and runs a single worker per container; use _APP_WORKERS_NUM to override.', 'introduction' => '0.13.0', 'default' => 6, 'required' => false, diff --git a/app/realtime.php b/app/realtime.php index c58c2a6e214..d3abda0e70a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -393,8 +393,13 @@ function checkForProjectUsage(Document $project): void $containerId = uniqid(); $statsDocument = null; -$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) - ?: intval(System::getEnv('_APP_CPU_NUM', swoole_cpu_num())) * intval(System::getEnv('_APP_WORKER_PER_CORE', 6)); +// Realtime is I/O bound: a single worker holding ~1200 websocket connections +// measured 0.06-0.35 cores, so extra workers add forked interpreter copies (~84% +// of a worker's footprint is fixed overhead, not connection state), duplicate the +// firehose subscription so every worker json_decodes every event, and split the +// accept distribution into a second, invisible balancing layer. Concurrency is the +// deployment's job. `_APP_WORKERS_NUM` still overrides. +$workerNumber = intval(System::getEnv('_APP_WORKERS_NUM', 0)) ?: 1; $adapter = new Adapter\Swoole(port: System::getEnv('PORT', 80)); $adapter @@ -788,19 +793,23 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $total = 0; $outboundBytes = 0; - // One frame per connection. `subscriptions` carries that connection's + // One frame per connection: `subscriptions` carries that connection's // matched subscription IDs, and those are ID::unique() per connection // (see the subscribe handler), so no two connections can ever share a - // frame. The grouping this replaces keyed on exactly those IDs, so it - // never collapsed -- it always built one group of one. - foreach ($receivers as $id => $matched) { - $data = $event['data']; - $data['subscriptions'] = array_keys($matched); + // frame. (The grouping this replaced keyed on exactly those IDs and so + // never collapsed -- it always built one group of one.) + // + // `subscriptions` is the only part that varies, and it is small, so the + // document is serialised once per event rather than once per subscriber. + // This loop's json_encode was 26% of realtime's on-CPU work during a + // fan-out burst, against 2.8% at rest. + $data = $event['data']; + unset($data['subscriptions']); + $tail = $data === [] ? '' : ',' . substr(json_encode($data), 1, -1); - $payloadJson = json_encode([ - 'type' => 'event', - 'data' => $data - ]); + foreach ($receivers as $id => $matched) { + $payloadJson = '{"type":"event","data":{"subscriptions":' + . json_encode(array_keys($matched)) . $tail . '}}'; $server->send([$id], $payloadJson); From cfefe127cdff08548220a3b10f462f6c21bbb435 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:18:37 +0100 Subject: [PATCH 18/37] fix(realtime): drop the now-dead outbound-bytes guard Inside `if ($total > 0)` at least one frame was sent, and every frame carries a non-empty literal envelope, so $outboundBytes cannot be zero. PHPStan proves it (greater.alwaysTrue) now that the frame is built by concatenation rather than json_encode, which could return false. Co-Authored-By: Claude Opus 5 (1M context) --- app/realtime.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index d3abda0e70a..1f56c4439a5 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -839,15 +839,12 @@ function logError(Throwable $error, string $action, array $tags = [], ?Document $projectId = $event['project'] ?? null; if (!empty($projectId)) { - $metrics = [ + // Reached only when $total > 0, and every frame carries the + // literal envelope, so outbound bytes are always non-zero. + triggerStats([ METRIC_REALTIME_CONNECTIONS_MESSAGES_SENT => $total, - ]; - - if ($outboundBytes > 0) { - $metrics[METRIC_REALTIME_OUTBOUND] = $outboundBytes; - } - - triggerStats($metrics, $projectId); + METRIC_REALTIME_OUTBOUND => $outboundBytes, + ], $projectId); } } From 05e1720770fda2371a74468615045706aeb3ecc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 03:38:58 +0000 Subject: [PATCH 19/37] docs(sdks): clarify Cloud vs self-hosted SDK compatibility Published SDK READMEs said they were compatible with server version X, which misled self-hosted users onto Cloud-ahead majors (e.g. Flutter 26 against Appwrite 1.9.6). Spell out that the target is Cloud and that self-hosted may need an older SDK release. Co-authored-by: chiragaggarwal5k --- src/Appwrite/Platform/Tasks/SDKs.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 059580029b0..d114e4c6b67 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -331,7 +331,7 @@ public function action(?string $platform, ?string $sdk, ?string $version, ?strin $examples = ($examples) ? \file_get_contents($examples) : ''; $changelog = $language['changelog'] ?? ''; $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; - $warning = '**This SDK is compatible with Appwrite server version ' . $version . '. For older versions, please check [previous releases](' . $language['url'] . '/releases).**'; + $warning = '**This SDK targets Appwrite server version ' . $version . ' as shipped on Appwrite Cloud.** Self-hosted releases can lag behind Cloud — if you run an older self-hosted build, use a matching older SDK from [previous releases](' . $language['url'] . '/releases) when APIs differ.'; $license = 'BSD-3-Clause'; $licenseContent = 'Copyright (c) ' . date('Y') . ' Appwrite (https://appwrite.io) and individual contributors. All rights reserved. From ba33088fe9fc040f21ada414e8ba4ea29127b6b4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 13:30:51 +0530 Subject: [PATCH 20/37] revert: drop the installer choice for the database products Reverts e92e56d274, de7f2585e9, 87c3467b23 and 90b4058db1, which asked in both installers which of DocumentsDB and VectorsDB to deploy and had the compose generator add an engine per enabled product. Neither product is ready to be turned on from a stock installation, so the choice is not worth its surface: two installer prompts, a web installer step, and per-product engine selection. This leaves 2.0.x matching main again for these files; the replacement, both products off behind an environment variable, lands on main in #13411 and reaches this branch through the usual sync. --- app/config/variables.php | 18 ----- app/controllers/shared/api.php | 13 ---- app/views/install/installer.phtml | 4 -- .../install/installer/js/modules/progress.js | 2 - .../install/installer/js/modules/state.js | 4 -- app/views/install/installer/js/modules/ui.js | 12 ---- app/views/install/installer/js/steps.js | 14 ---- .../installer/templates/steps/step-1.phtml | 28 -------- .../installer/templates/steps/step-4.phtml | 8 --- docker-compose.yml | 8 --- src/Appwrite/Docker/Compose/Generator.php | 44 +------------ .../Installer/Http/Installer/Install.php | 6 -- .../Installer/Http/Installer/View.php | 2 - src/Appwrite/Platform/Tasks/Install.php | 36 +--------- tests/unit/Docker/Compose/GeneratorTest.php | 65 +------------------ 15 files changed, 5 insertions(+), 259 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 844f477ef7a..195ea7da47c 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -616,24 +616,6 @@ 'question' => '', 'filter' => '' ], - [ - 'name' => '_APP_DOCUMENTSDB', - 'description' => 'Enables the DocumentsDB API. It runs on MongoDB, so the installation needs a reachable MongoDB while this is enabled. Set to disabled to leave MongoDB out; the /v1/documentsdb routes then return a service disabled error. Default value is: enabled.', - 'introduction' => '2.0.0', - 'default' => 'enabled', - 'required' => false, - 'question' => 'Enable DocumentsDB? It requires MongoDB (Y/n)', - 'filter' => '' - ], - [ - 'name' => '_APP_VECTORSDB', - 'description' => 'Enables the VectorsDB API. It runs on PostgreSQL, so the installation needs a reachable PostgreSQL while this is enabled. Set to disabled to leave PostgreSQL out; the /v1/vectorsdb routes then return a service disabled error. Default value is: enabled.', - 'introduction' => '2.0.0', - 'default' => 'enabled', - 'required' => false, - 'question' => 'Enable VectorsDB? It requires PostgreSQL (Y/n)', - 'filter' => '' - ], ], ], [ diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 8a9c0e2da0d..2bb43893104 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -461,19 +461,6 @@ if (! empty($method)) { $namespace = \strtolower($method->getNamespace()); - // An operator turns a database product off when its engine is not deployed, - // so the route is unavailable to everyone, keys and privileged roles included. - $productToggles = [ - 'documentsdb' => '_APP_DOCUMENTSDB', - 'vectorsdb' => '_APP_VECTORSDB', - ]; - if ( - isset($productToggles[$namespace]) - && System::getEnv($productToggles[$namespace], 'enabled') !== 'enabled' - ) { - throw new Exception(Exception::GENERAL_SERVICE_DISABLED); - } - if ( array_key_exists($namespace, $project->getAttribute('services', [])) && ! $project->getAttribute('services', [])[$namespace] diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index 7d919472b85..ae858912f65 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -9,8 +9,6 @@ $defaultAppDomain = $vars['_APP_DOMAIN']['default'] ?? 'localhost'; $defaultAppDomain = ($defaultAppDomain === 'traefik') ? 'localhost' : $defaultAppDomain; $defaultEmailCertificates ??= ''; $defaultForceHttps ??= ($vars['_APP_OPTIONS_FORCE_HTTPS']['default'] ?? 'disabled') === 'enabled'; -$defaultDocumentsDB ??= ($vars['_APP_DOCUMENTSDB']['default'] ?? 'enabled') !== 'disabled'; -$defaultVectorsDB ??= ($vars['_APP_VECTORSDB']['default'] ?? 'enabled') !== 'disabled'; $defaultDatabase = $vars['_APP_DB_ADAPTER']['default'] ?? 'postgresql'; $enabledDatabases ??= ['postgresql', 'mariadb', 'mongodb']; $isLocalInstall ??= false; @@ -67,8 +65,6 @@ $installerVersion = @filemtime(__DIR__ . '/installer/js/installer.js') ?: time() data-default-app-domain="" data-default-email-certificates="" data-default-force-https="" - data-default-documentsdb="" - data-default-vectorsdb="" data-default-secret-key="" data-default-assistant-openai-key="" data-default-database="" diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 6f0fb31e54f..c1e59add0e4 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -369,8 +369,6 @@ httpsPort: normalizedHttpsPort, database: formState?.database || 'postgresql', topology: formState?.topology || 'combined', - documentsDB: formState?.documentsDB !== false, - vectorsDB: formState?.vectorsDB !== false, appDomain: normalizedDomain, emailCertificates: normalizedEmail, forceHttps: formState?.forceHttps === true, diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index a1b878d7a60..408c3d28e9b 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -20,8 +20,6 @@ opensslKey: null, assistantOpenAIKey: null, topology: 'combined', - documentsDB: null, - vectorsDB: null, accountEmail: null, accountPassword: null }; @@ -49,8 +47,6 @@ setStateIfEmpty('httpsPort', data.defaultHttpsPort); setStateIfEmpty('emailCertificates', data.defaultEmailCertificates); setStateIfEmpty('forceHttps', data.defaultForceHttps === 'true'); - setStateIfEmpty('documentsDB', data.defaultDocumentsdb !== 'false'); - setStateIfEmpty('vectorsDB', data.defaultVectorsdb !== 'false'); setStateIfEmpty('opensslKey', data.defaultSecretKey); setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey); if (data.lockedDatabase) { diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index b1f135c98ce..1ef47f69d8f 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -265,18 +265,6 @@ httpsBadge.classList.add(forceHttps ? 'badge-success' : 'badge-neutral'); } - const productBadges = [ - ['[data-review-documentsdb-badge]', formState?.documentsDB !== false], - ['[data-review-vectorsdb-badge]', formState?.vectorsDB !== false], - ]; - productBadges.forEach(([selector, enabled]) => { - const badge = root.querySelector(selector); - if (!badge) return; - badge.textContent = enabled ? 'Enabled' : 'Disabled'; - badge.classList.remove('badge-success', 'badge-neutral'); - badge.classList.add(enabled ? 'badge-success' : 'badge-neutral'); - }); - const assistantBadge = root.querySelector('[data-review-assistant-badge]'); if (assistantBadge) { const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim()); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 8def94b17f5..00176bce5c2 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -122,8 +122,6 @@ State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value); State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value); State.setStateIfEmpty?.('forceHttps', root.querySelector('#force-https')?.checked); - State.setStateIfEmpty?.('documentsDB', root.querySelector('#documentsdb')?.checked); - State.setStateIfEmpty?.('vectorsDB', root.querySelector('#vectorsdb')?.checked); State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value); }; @@ -145,16 +143,6 @@ forceHttps.checked = formState.forceHttps; } - const documentsDB = root.querySelector('#documentsdb'); - if (documentsDB && typeof formState.documentsDB === 'boolean') { - documentsDB.checked = formState.documentsDB; - } - - const vectorsDB = root.querySelector('#vectorsdb'); - if (vectorsDB && typeof formState.vectorsDB === 'boolean') { - vectorsDB.checked = formState.vectorsDB; - } - const assistantKey = root.querySelector('#assistant-openai-key'); if (assistantKey && formState.assistantOpenAIKey) { assistantKey.value = formState.assistantOpenAIKey; @@ -224,8 +212,6 @@ bindInputToState(httpsPort, 'httpsPort'); bindInputToState(sslEmail, 'emailCertificates'); bindCheckboxToState(forceHttps, 'forceHttps'); - bindCheckboxToState(root.querySelector('#documentsdb'), 'documentsDB'); - bindCheckboxToState(root.querySelector('#vectorsdb'), 'vectorsDB'); bindInputToState(assistantKey, 'assistantOpenAIKey'); bindErrorClear?.(hostname); diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index 99159303046..f157dad933f 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -10,8 +10,6 @@ $defaultEmailCertificates ??= ''; $defaultForceHttps ??= false; $defaultAssistantOpenAIKey ??= ''; $defaultDatabase ??= 'postgresql'; -$defaultDocumentsDB ??= true; -$defaultVectorsDB ??= true; $enabledDatabases ??= ['postgresql', 'mariadb', 'mongodb']; $selectedDatabase = $lockedDatabase ?: $defaultDatabase; $isDatabaseLocked = !empty($lockedDatabase); @@ -109,32 +107,6 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
- - - -
-
- Enabled -
DocumentsDB
-
-
- Enabled -
VectorsDB
-
diff --git a/docker-compose.yml b/docker-compose.yml index 44d8ed18725..f20260bc1f9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -245,8 +245,6 @@ services: - _APP_GEO_SECRET - _APP_GEO_ENDPOINT - _APP_LIMIT_DATABASE_BATCH - - _APP_DOCUMENTSDB - - _APP_VECTORSDB appwrite-console: logging: driver: json-file @@ -340,8 +338,6 @@ services: - _APP_LOGGING_CONFIG_REALTIME - _APP_DATABASE_SHARED_TABLES - _APP_LIMIT_DATABASE_BATCH - - _APP_DOCUMENTSDB - - _APP_VECTORSDB - _APP_POOL_ADAPTER=swoole appwrite-worker: @@ -505,8 +501,6 @@ services: - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE - _APP_LIMIT_DATABASE_BATCH - - _APP_DOCUMENTSDB - - _APP_VECTORSDB appwrite-task-scheduler: entrypoint: schedule @@ -763,8 +757,6 @@ services: - _APP_QUEUE_NAME - _APP_DATABASE_SHARED_TABLES - _APP_LIMIT_DATABASE_BATCH - - _APP_DOCUMENTSDB - - _APP_VECTORSDB appwrite-worker-builds: profiles: - separate diff --git a/src/Appwrite/Docker/Compose/Generator.php b/src/Appwrite/Docker/Compose/Generator.php index af174e7684b..316d1c7a397 100644 --- a/src/Appwrite/Docker/Compose/Generator.php +++ b/src/Appwrite/Docker/Compose/Generator.php @@ -25,17 +25,6 @@ class Generator ], ]; - /** - * Engine each specialised database product runs on. DocumentsDB only runs on - * MongoDB and VectorsDB only on PostgreSQL, so an enabled product needs its - * engine even when a different one backs the platform. A disabled product - * leaves its engine out, keeping the installation to what it actually uses. - */ - private const array PRODUCT_BACKING_SERVICES = [ - 'enableDocumentsDB' => 'mongodb', - 'enableVectorsDB' => 'postgresql', - ]; - private const array OPTIONAL_SERVICES = [ 'enableAssistant' => 'appwrite-assistant', ]; @@ -71,8 +60,6 @@ class Generator 'database' => 'postgresql', 'hostPath' => '', 'enableAssistant' => false, - 'enableDocumentsDB' => true, - 'enableVectorsDB' => true, 'topology' => 'combined', ]; @@ -172,23 +159,8 @@ private function normalizeParams(array $params): array } /** - * Engines the enabled database products need, on top of the platform engine. - * - * @return array + * @return string[] */ - private function getRequiredBackingServices(): array - { - $services = []; - - foreach (self::PRODUCT_BACKING_SERVICES as $param => $service) { - if (!empty($this->params[$param])) { - $services[] = $service; - } - } - - return $services; - } - private function getSelectableServices(): array { $services = []; @@ -208,15 +180,9 @@ private function filterServices(array $services): array { foreach (self::SELECTABLE_SERVICE_GROUPS as $param => $config) { foreach ($config['services'] as $service) { - if ($service === $this->params[$param]) { - continue; - } - - if (\in_array($service, $this->getRequiredBackingServices(), true)) { - continue; + if ($service !== $this->params[$param]) { + unset($services[$service]); } - - unset($services[$service]); } } @@ -285,10 +251,6 @@ private function filterVolumes(array $volumes): array continue; } - if (\in_array($service, $this->getRequiredBackingServices(), true)) { - continue; - } - foreach ($names as $name) { unset($volumes[$name]); } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 5ec069a7ba3..590a62a75ce 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -43,8 +43,6 @@ public function __construct() ->param('accountPassword', '', new Password(allowEmpty: true), 'Account password', true) ->param('database', '', new WhiteList(['postgresql', 'mariadb', 'mongodb']), 'Database adapter', true) ->param('topology', 'combined', new WhiteList(['combined', 'separate']), 'Worker and scheduler topology', true) - ->param('documentsDB', true, new \Utopia\Validator\Boolean(true), 'Deploy DocumentsDB and the MongoDB it runs on', true) - ->param('vectorsDB', true, new \Utopia\Validator\Boolean(true), 'Deploy VectorsDB and the PostgreSQL it runs on', true) ->param('installId', '', new Text(64, 0), 'Installation ID', true) ->param('retryStep', null, new Nullable(new WhiteList([ Server::STEP_CONFIG_FILES, @@ -76,8 +74,6 @@ public function action( string $accountPassword, string $database, string $topology, - bool $documentsDB, - bool $vectorsDB, string $installId, ?string $retryStep, bool $migrate, @@ -237,8 +233,6 @@ public function action( '_APP_EMAIL_CERTIFICATES' => $emailCertificates, '_APP_DB_ADAPTER' => $lockedDatabase ?? ($database ?: 'postgresql'), '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, - '_APP_DOCUMENTSDB' => $documentsDB ? 'enabled' : 'disabled', - '_APP_VECTORSDB' => $vectorsDB ? 'enabled' : 'disabled', ]; $previousHadError = is_array($existing) && isset($existing['error']); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php index 1bacc6e735f..0ae21d402d8 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/View.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -50,8 +50,6 @@ public function action(int $step, ?string $partial, Request $request, Response $ $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? ''; $defaultForceHttps = ($vars['_APP_OPTIONS_FORCE_HTTPS']['default'] ?? 'disabled') === 'enabled'; - $defaultDocumentsDB = ($vars['_APP_DOCUMENTSDB']['default'] ?? 'enabled') !== 'disabled'; - $defaultVectorsDB = ($vars['_APP_VECTORSDB']['default'] ?? 'enabled') !== 'disabled'; if ($isLocalInstall && empty($defaultEmailCertificates)) { $defaultEmailCertificates = 'walterobrien@example.com'; } diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 74ba360ea8d..5c3ca128528 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -259,33 +259,6 @@ public function action( $enableAssistant = true; } - // DocumentsDB runs on MongoDB and VectorsDB on PostgreSQL. Turning one off keeps - // its engine out of the installation, so only deploy what is actually used. - $products = [ - 'enableDocumentsDB' => ['var' => '_APP_DOCUMENTSDB', 'label' => 'DocumentsDB', 'engine' => 'MongoDB'], - 'enableVectorsDB' => ['var' => '_APP_VECTORSDB', 'label' => 'VectorsDB', 'engine' => 'PostgreSQL'], - ]; - $enabledProducts = []; - foreach ($products as $param => $product) { - // On an upgrade the existing .env has already seeded the default; on a fresh - // install the environment is how a scripted run states its choice. - $current = $existingInstallation - ? ($vars[$product['var']]['default'] ?? 'enabled') !== 'disabled' - : System::getEnv($product['var'], 'enabled') !== 'disabled'; - - if ($interactive === 'Y' && Console::isInteractive()) { - $answer = Console::confirm( - "Enable {$product['label']}? It requires {$product['engine']} (Y/n)" - . ($existingInstallation ? ($current ? ' [Currently enabled]' : ' [Currently disabled]') : '') - ); - $enabledProducts[$param] = empty($answer) ? $current : \strtolower($answer) === 'y'; - } else { - $enabledProducts[$param] = $current; - } - - $vars[$product['var']]['default'] = $enabledProducts[$param] ? 'enabled' : 'disabled'; - } - if (empty($httpPort)) { $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')'); $httpPort = ($httpPort) ?: $defaultHttpPort; @@ -660,8 +633,6 @@ public function performInstallation( 'database' => $database, 'hostPath' => $this->hostPath, 'enableAssistant' => $enableAssistant, - 'enableDocumentsDB' => ($input['_APP_DOCUMENTSDB'] ?? 'enabled') !== 'disabled', - 'enableVectorsDB' => ($input['_APP_VECTORSDB'] ?? 'enabled') !== 'disabled', 'topology' => $this->topology, ]); @@ -719,12 +690,7 @@ public function performInstallation( $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages); } - // DocumentsDB runs on MongoDB, so the service, and its bind-mounted support - // files, can be present even when another engine backs the platform. - $needsMongo = $database === 'mongodb' - || ($input['_APP_DOCUMENTSDB'] ?? 'enabled') !== 'disabled'; - - if ($needsMongo && !$useExistingConfig && $startIndex <= 1) { + if ($database === 'mongodb' && !$useExistingConfig && $startIndex <= 1) { $this->copyMongoFilesIfNeeded(); } diff --git a/tests/unit/Docker/Compose/GeneratorTest.php b/tests/unit/Docker/Compose/GeneratorTest.php index 190256975fb..8b0f259aafb 100644 --- a/tests/unit/Docker/Compose/GeneratorTest.php +++ b/tests/unit/Docker/Compose/GeneratorTest.php @@ -25,8 +25,6 @@ public function testSelectsDatabaseService(): void $compose = $this->render([ 'database' => 'mariadb', 'enableAssistant' => false, - 'enableDocumentsDB' => false, - 'enableVectorsDB' => false, ]); $this->assertArrayHasKey('mariadb', $compose['services']); @@ -39,10 +37,7 @@ public function testSelectsDatabaseService(): void public function testDefaultsToPostgreSQL(): void { - $compose = $this->render([ - 'enableDocumentsDB' => false, - 'enableVectorsDB' => false, - ]); + $compose = $this->render(); $this->assertArrayHasKey('postgresql', $compose['services']); $this->assertArrayNotHasKey('mongodb', $compose['services']); @@ -52,64 +47,6 @@ public function testDefaultsToPostgreSQL(): void $this->assertArrayNotHasKey('appwrite-mariadb', $compose['volumes']); } - public function testEnabledProductsAddTheirEngine(): void - { - $compose = $this->render([ - 'database' => 'mariadb', - 'enableDocumentsDB' => true, - 'enableVectorsDB' => true, - ]); - - $this->assertArrayHasKey('mariadb', $compose['services']); - $this->assertArrayHasKey('mongodb', $compose['services'], 'DocumentsDB runs on MongoDB'); - $this->assertArrayHasKey('postgresql', $compose['services'], 'VectorsDB runs on PostgreSQL'); - $this->assertArrayHasKey('appwrite-mongodb', $compose['volumes']); - $this->assertArrayHasKey('appwrite-postgresql', $compose['volumes']); - } - - public function testDisabledProductDropsItsEngine(): void - { - $compose = $this->render([ - 'database' => 'postgresql', - 'enableDocumentsDB' => false, - 'enableVectorsDB' => true, - ]); - - $this->assertArrayNotHasKey('mongodb', $compose['services']); - $this->assertArrayNotHasKey('appwrite-mongodb', $compose['volumes']); - $this->assertArrayHasKey('postgresql', $compose['services'], 'still the platform engine'); - } - - public function testProductReusesThePlatformEngine(): void - { - $compose = $this->render([ - 'database' => 'mongodb', - 'enableDocumentsDB' => true, - 'enableVectorsDB' => false, - ]); - - $engines = \array_intersect( - ['postgresql', 'mariadb', 'mongodb'], - \array_keys($compose['services']) - ); - - $this->assertSame(['mongodb'], \array_values($engines), 'DocumentsDB reuses MongoDB rather than adding a second engine'); - $this->assertArrayNotHasKey('postgresql', $compose['services']); - } - - public function testPlatformEngineSurvivesItsProductBeingDisabled(): void - { - $compose = $this->render([ - 'database' => 'mongodb', - 'enableDocumentsDB' => false, - 'enableVectorsDB' => false, - ]); - - $this->assertArrayHasKey('mongodb', $compose['services'], 'selected as the platform engine'); - $this->assertArrayHasKey('appwrite-mongodb', $compose['volumes']); - $this->assertArrayNotHasKey('postgresql', $compose['services']); - } - public function testTogglesAssistantService(): void { $disabled = $this->render([ From 98f425f3230a23f75e072610b27334598a4d8cad Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 13:36:20 +0530 Subject: [PATCH 21/37] chore: keep the cache buster at the 1.9.6 value It only has to change when a response shape changes in a way a cached entry would get wrong. 2.0 does not, so leaving it at 4327 keeps caches warm through the upgrade rather than discarding every entry for nothing. --- app/init/constants.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/init/constants.php b/app/init/constants.php index d9810f3b7d3..f6e0abae849 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -99,7 +99,7 @@ const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours -const APP_CACHE_BUSTER = 4328; +const APP_CACHE_BUSTER = 4327; const APP_VERSION_STABLE = '2.0.0'; const APP_DATABASE_ATTRIBUTE_EMAIL = 'email'; const APP_DATABASE_ATTRIBUTE_ENUM = 'enum'; From 90798d5a7085b33293f83bdf351de40e85251e9a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 14:09:03 +0530 Subject: [PATCH 22/37] fix: normalize execution header values --- .../Utopia/Response/Model/Execution.php | 33 +++++++++++++++++++ .../Utopia/Response/Model/ExecutionTest.php | 14 ++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Appwrite/Utopia/Response/Model/Execution.php b/src/Appwrite/Utopia/Response/Model/Execution.php index 9008ceb7fa9..a147781e8df 100644 --- a/src/Appwrite/Utopia/Response/Model/Execution.php +++ b/src/Appwrite/Utopia/Response/Model/Execution.php @@ -5,6 +5,7 @@ use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; use Utopia\Database\DateTime; +use Utopia\Database\Document; use Utopia\Database\Helpers\Role; class Execution extends Model @@ -137,6 +138,38 @@ public function __construct() ; } + /** + * Normalize HTTP header values to the public string contract. + */ + public function filter(Document $document): Document + { + foreach (['requestHeaders', 'responseHeaders'] as $attribute) { + $headers = $document->getAttribute($attribute, []); + if (!\is_array($headers)) { + continue; + } + + foreach ($headers as $index => $header) { + if ($header instanceof Document) { + $value = $header->getAttribute('value'); + if (\is_array($value)) { + $header->setAttribute('value', \implode(', ', $value)); + } + continue; + } + + if (\is_array($header) && \is_array($header['value'] ?? null)) { + $header['value'] = \implode(', ', $header['value']); + $headers[$index] = $header; + } + } + + $document->setAttribute($attribute, $headers); + } + + return $document; + } + /** * Get Name * diff --git a/tests/unit/Utopia/Response/Model/ExecutionTest.php b/tests/unit/Utopia/Response/Model/ExecutionTest.php index d6b5f50ebf2..fa30687920d 100644 --- a/tests/unit/Utopia/Response/Model/ExecutionTest.php +++ b/tests/unit/Utopia/Response/Model/ExecutionTest.php @@ -15,10 +15,24 @@ public function testPreservesResourceIdentity(): void $execution = (new Execution())->filter(new Document([ 'resourceType' => 'sites', 'resourceId' => 'site-id', + 'requestHeaders' => [ + ['name' => 'host', 'value' => ['example.com']], + ['name' => 'user-agent', 'value' => ['Agent/1.0', 'Agent/2.0']], + ['name' => 'content-type', 'value' => 'application/json'], + ], + 'responseHeaders' => [ + new Document(['name' => 'content-length', 'value' => ['42']]), + ], ])); $this->assertSame('site-id', $execution->getAttribute('resourceId')); $this->assertSame('sites', $execution->getAttribute('resourceType')); + $this->assertSame([ + ['name' => 'host', 'value' => 'example.com'], + ['name' => 'user-agent', 'value' => 'Agent/1.0, Agent/2.0'], + ['name' => 'content-type', 'value' => 'application/json'], + ], $execution->getAttribute('requestHeaders')); + $this->assertSame('42', $execution->getAttribute('responseHeaders')[0]->getAttribute('value')); } public function testResourceIdentityIsRequired(): void From 3f3367abbbab841b920999e58fbd597681f08f0b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 14:53:55 +0530 Subject: [PATCH 23/37] fix(installer): keep the topology the operator picked Every step render calls applyBodyDefaults(), which assigned the topology from the body dataset unconditionally. The dataset carries the value the page was first rendered with, so moving off step one put it back to combined and the install request carried combined however the radio was set. Picking "Separate" produced a combined install: one worker and one scheduler instead of a container per queue, with no sign anything had been ignored. It now seeds the same way as every other field, so the dataset supplies a starting value and a choice already made is left alone. The initial value moves to null for that -- setStateIfEmpty only fills what is empty, and 'combined' never was. (cherry picked from commit b52ded73d8ba0bfc7a4d04f551784675bd1a9955) --- app/views/install/installer/js/modules/state.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 408c3d28e9b..beae3e4324c 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -19,7 +19,7 @@ forceHttps: null, opensslKey: null, assistantOpenAIKey: null, - topology: 'combined', + topology: null, accountEmail: null, accountPassword: null }; @@ -53,7 +53,7 @@ formState.database = data.lockedDatabase; } if (data.topology === 'combined' || data.topology === 'separate') { - formState.topology = data.topology; + setStateIfEmpty('topology', data.topology); } if (!isUpgradeMode?.()) { setStateIfEmpty('database', data.defaultDatabase); From 4ede88f0cb8fd87ea049a8a4ecd3a92e46a66807 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 15:43:30 +0530 Subject: [PATCH 24/37] fix: align response models with JSON payloads (#13419) --- .../Response/Model/FrameworkAdapter.php | 1 + .../Utopia/Response/Model/Project.php | 9 +++- .../Utopia/Response/Model/Provider.php | 22 +++++++- .../Response/Model/TemplateFramework.php | 1 + .../Utopia/Response/Model/TemplateSite.php | 1 + tests/unit/SDK/Specification/FormatTest.php | 50 ++++++++++++++++++- tests/unit/Utopia/ResponseTest.php | 25 +++++++++- 7 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/FrameworkAdapter.php b/src/Appwrite/Utopia/Response/Model/FrameworkAdapter.php index afbd78eda4e..5f241afd875 100644 --- a/src/Appwrite/Utopia/Response/Model/FrameworkAdapter.php +++ b/src/Appwrite/Utopia/Response/Model/FrameworkAdapter.php @@ -39,6 +39,7 @@ public function __construct() 'description' => 'Name of fallback file to use instead of 404 page. If null, Appwrite 404 page will be displayed.', 'default' => null, 'example' => 'index.html', + 'required' => false, ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index c86cedbe046..cc6ad4768ee 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -156,8 +156,8 @@ public function __construct() ->addRule('onboarding', [ 'type' => self::TYPE_JSON, 'description' => 'Stage progress (completed or skipped) with timestamps and actor types, keyed by stage id.', - 'default' => [], - 'example' => [], + 'default' => new \stdClass(), + 'example' => new \stdClass(), ]) // Resource: Auth methods @@ -242,6 +242,11 @@ public function filter(Document $document): Document $this->expandConsoleAccessedAt($document); $document->setAttribute('wafEnabled', (bool) $document->getAttribute('wafEnabled', false)); + $onboarding = $document->getAttribute('onboarding', []); + if (\is_array($onboarding) && empty($onboarding)) { + $document->setAttribute('onboarding', new \stdClass()); + } + return $document; } diff --git a/src/Appwrite/Utopia/Response/Model/Provider.php b/src/Appwrite/Utopia/Response/Model/Provider.php index d3de061aabc..65c35f7cbe0 100644 --- a/src/Appwrite/Utopia/Response/Model/Provider.php +++ b/src/Appwrite/Utopia/Response/Model/Provider.php @@ -4,6 +4,7 @@ use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model; +use Utopia\Database\Document; class Provider extends Model { @@ -55,7 +56,7 @@ public function __construct() ->addRule('credentials', [ 'type' => self::TYPE_JSON, 'description' => 'Provider credentials.', - 'default' => [], + 'default' => new \stdClass(), 'example' => [ 'key' => '123456789' ], @@ -63,7 +64,7 @@ public function __construct() ->addRule('options', [ 'type' => self::TYPE_JSON, 'description' => 'Provider options.', - 'default' => [], + 'default' => new \stdClass(), 'required' => false, 'example' => [ 'from' => 'sender-email@mydomain' @@ -71,6 +72,23 @@ public function __construct() ]); } + /** + * Process Document before returning it to the client + * + * @return Document + */ + public function filter(Document $document): Document + { + foreach (['credentials', 'options'] as $attribute) { + $value = $document->getAttribute($attribute); + if (\is_array($value) && empty($value)) { + $document->setAttribute($attribute, new \stdClass()); + } + } + + return $document; + } + /** * Get Name * diff --git a/src/Appwrite/Utopia/Response/Model/TemplateFramework.php b/src/Appwrite/Utopia/Response/Model/TemplateFramework.php index ae94ca44253..f4ad1f3cdc0 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateFramework.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateFramework.php @@ -63,6 +63,7 @@ public function __construct() 'description' => 'Fallback file for SPA. Only relevant for static serve runtime.', 'default' => null, 'example' => 'index.html', + 'required' => false, ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/TemplateSite.php b/src/Appwrite/Utopia/Response/Model/TemplateSite.php index 053742dfdb7..afd49b10b86 100644 --- a/src/Appwrite/Utopia/Response/Model/TemplateSite.php +++ b/src/Appwrite/Utopia/Response/Model/TemplateSite.php @@ -33,6 +33,7 @@ public function __construct() 'description' => 'URL hosting a template demo.', 'default' => '', 'example' => 'https://nextjs-starter.appwrite.network/', + 'required' => false, ]) ->addRule('screenshotDark', [ 'type' => self::TYPE_STRING, diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index c0478f66c86..96d05875cf2 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -23,6 +23,7 @@ use Appwrite\Utopia\Response\Model\AttributeLine; use Appwrite\Utopia\Response\Model\Error as ErrorModel; use Appwrite\Utopia\Response\Model\ErrorDev; +use Appwrite\Utopia\Response\Model\FrameworkAdapter; use Appwrite\Utopia\Response\Model\HealthStatus; use Appwrite\Utopia\Response\Model\Metric; use Appwrite\Utopia\Response\Model\Migration; @@ -36,6 +37,10 @@ use Appwrite\Utopia\Response\Model\Preferences; use Appwrite\Utopia\Response\Model\Provider; use Appwrite\Utopia\Response\Model\Team; +use Appwrite\Utopia\Response\Model\TemplateFramework; +use Appwrite\Utopia\Response\Model\TemplateSite; +use Appwrite\Utopia\Response\Model\TemplateVariable; +use Appwrite\Utopia\Response\Model\UsageDataPoint; use Appwrite\Utopia\Response\Model\UsageProject; use Appwrite\Utopia\Response\Model\User; use Appwrite\Utopia\Response\Model\Webhook; @@ -1035,7 +1040,7 @@ public function testAdditionalParametersAreIncludedInRequestBody(): void $this->assertSame('object', $openApiQuery['type']); } - public function testJsonModelRulesKeepAdditionalPropertiesAndSkipNullable(): void + public function testJsonAndNullableModelRulesEmitExpectedSchemas(): void { Method::$processed = []; Method::$errors = []; @@ -1058,15 +1063,56 @@ public function testJsonModelRulesKeepAdditionalPropertiesAndSkipNullable(): voi $models = [ new Provider(), + new FrameworkAdapter(), + new TemplateFramework(), + new TemplateSite(), + new TemplateVariable(), + new UsageDataPoint(), new ErrorModel(), ]; + $routes = [$route]; + + foreach ([ + Response::MODEL_FRAMEWORK_ADAPTER, + Response::MODEL_TEMPLATE_FRAMEWORK, + Response::MODEL_TEMPLATE_SITE, + Response::MODEL_USAGE_DATA_POINT, + ] as $model) { + $routes[] = (new Route('GET', '/v1/tests/' . $model)) + ->desc('Get test response model') + ->label('sdk', new Method( + namespace: 'test', + group: null, + name: 'get' . \ucfirst($model), + description: 'Get test response model.', + auth: [], + responses: [ + new SDKResponse( + code: 200, + model: $model, + ), + ], + )); + } - $openApi = (new OpenAPI3(new Container(), [], [$route], $models, [], 0, 'console'))->parse(); + $openApi = (new OpenAPI3(new Container(), [], $routes, $models, [], 0, 'console'))->parse(); $openApiOptions = $openApi['components']['schemas']['provider']['properties']['options']; $this->assertTrue($openApiOptions['additionalProperties']); $this->assertArrayNotHasKey('nullable', $openApiOptions); + + foreach ([ + Response::MODEL_FRAMEWORK_ADAPTER => 'fallbackFile', + Response::MODEL_TEMPLATE_FRAMEWORK => 'fallbackFile', + Response::MODEL_TEMPLATE_SITE => 'demoUrl', + Response::MODEL_USAGE_DATA_POINT => 'time', + ] as $model => $property) { + $schema = $openApi['components']['schemas'][$model]; + + $this->assertTrue($schema['properties'][$property]['nullable']); + $this->assertNotContains($property, $schema['required']); + } } public function testQueriesSubclassesEmitArrayOfStrings(): void diff --git a/tests/unit/Utopia/ResponseTest.php b/tests/unit/Utopia/ResponseTest.php index cb2dfe7de4a..92ef21c0a2d 100644 --- a/tests/unit/Utopia/ResponseTest.php +++ b/tests/unit/Utopia/ResponseTest.php @@ -7,6 +7,7 @@ use Appwrite\Models\Project as GeneratedProject; use Appwrite\Utopia\Response; use Appwrite\Utopia\Response\Model\Project as ProjectModel; +use Appwrite\Utopia\Response\Model\Provider as ProviderModel; use Exception; use PHPUnit\Framework\TestCase; use ReflectionProperty; @@ -115,6 +116,7 @@ public function testResponseModelRequiredException(): void public function testProjectResponseCanHydrateGeneratedSdkProjectWithoutOAuth2Fields(): void { $this->response->setModel(new ProjectModel()); + $this->response->setModel(new ProviderModel()); $project = $this->response->output(new Document([ '$id' => 'project', @@ -127,7 +129,28 @@ public function testProjectResponseCanHydrateGeneratedSdkProjectWithoutOAuth2Fie $project['wafEnabled'] = false; - $generated = GeneratedProject::from($project); + $provider = $this->response->output(new Document([ + 'credentials' => [], + 'options' => [], + ]), Response::MODEL_PROVIDER); + $populatedProvider = $this->response->output(new Document([ + 'credentials' => ['key' => 'secret'], + 'options' => ['from' => 'sender@example.com'], + ]), Response::MODEL_PROVIDER); + + $this->assertInstanceOf(\stdClass::class, $project['onboarding']); + $this->assertInstanceOf(\stdClass::class, $provider['credentials']); + $this->assertInstanceOf(\stdClass::class, $provider['options']); + $this->assertSame('{}', \json_encode($project['onboarding'])); + $this->assertSame('{}', \json_encode($provider['credentials'])); + $this->assertSame('{}', \json_encode($provider['options'])); + $this->assertSame(['key' => 'secret'], $populatedProvider['credentials']); + $this->assertSame(['from' => 'sender@example.com'], $populatedProvider['options']); + + // Match the JSON round trip performed by SDK clients. Empty objects on + // the wire decode to associative arrays in the generated PHP SDK. + $decoded = \json_decode(\json_encode($project, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR); + $generated = GeneratedProject::from($decoded); foreach ([ 'oAuth2ServerEnabled', From 02f860edad78920b32b0d9c4d468c4bda926f3bb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 29 Aug 2026 22:00:14 +0300 Subject: [PATCH 25/37] feat(installer): tidy up the web installer steps - Names the worker split "Container topology" rather than "Workers and schedulers", since other queues may join the same choice later, and drops the row that repeated it on the review screen. - Sizes the card to the step being shown. A single running maximum meant the tallest step set the floor for every other one, so the short ones carried its leftover height as dead space. Height now rides the same curve and frame as the panel cross-fade, and the outgoing panel holds its own height instead of being squeezed as the card resizes. - Field labels drop to 12px, leaving the section heading above them larger. - An SSL certificate email that was never entered reads as an "Empty" tag, matching how the other absent settings on that panel are shown. - The account step can be skipped. The installer already skips creating an account when either field is blank, so only the form was insisting; half an account is still refused. - HTTPS is on by default, since a public API is normally served over TLS. Local and plain HTTP installations have to turn it off. --- app/config/variables.php | 4 +-- app/views/install/installer/css/styles.css | 12 +++++++ app/views/install/installer/js/installer.js | 34 ++++++++++++++----- app/views/install/installer/js/modules/ui.js | 16 +++++++-- app/views/install/installer/js/steps.js | 16 ++++++--- .../installer/templates/steps/step-1.phtml | 16 ++++----- .../installer/templates/steps/step-2.phtml | 2 +- .../installer/templates/steps/step-3.phtml | 8 ++--- .../installer/templates/steps/step-4.phtml | 7 ++-- 9 files changed, 81 insertions(+), 34 deletions(-) diff --git a/app/config/variables.php b/app/config/variables.php index 195ea7da47c..2ead909ea85 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -63,9 +63,9 @@ ], [ 'name' => '_APP_OPTIONS_FORCE_HTTPS', - 'description' => 'Controls whether Appwrite generates HTTPS API URLs and enforces HTTPS for incoming API requests. Set to \'enabled\' whenever the public API is served over HTTPS, including when TLS is terminated by a reverse proxy. When enabled, HTTP GET requests are redirected to HTTPS and other HTTP requests are rejected. The default value is \'disabled\' to support local and plain HTTP installations.', + 'description' => 'Controls whether Appwrite generates HTTPS API URLs and enforces HTTPS for incoming API requests. When enabled, HTTP GET requests are redirected to HTTPS and other HTTP requests are rejected. Leave it enabled whenever the public API is served over HTTPS, including when TLS is terminated by a reverse proxy. Set it to \'disabled\' for local or plain HTTP installations, which have no certificate to redirect to. The default value is \'enabled\'.', 'introduction' => '', - 'default' => 'disabled', + 'default' => 'enabled', 'required' => false, 'question' => '', 'filter' => '' diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index e6f9e87b8d9..875d4f669fb 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -476,6 +476,13 @@ body { min-height: var(--step-min-height, auto); flex: 1 1 auto; overflow: hidden; + transition: min-height var(--duration-medium) var(--ease-standard); +} + +@media (prefers-reduced-motion: reduce) { + .installer-step { + transition: none; + } } .installer-page[data-upgrade='true'] .installer-step { @@ -541,6 +548,11 @@ body { .step-panel.is-exiting { opacity: 0; pointer-events: none; + position: absolute; + top: 0; + left: 0; + height: auto; + overflow: hidden; } .step-panel.is-measure { diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index 07ec7bb1efd..cd33af642f0 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -48,7 +48,7 @@ const STEP_CONFIG = buildStepConfig(); const stepCache = new Map(); - let maxStepHeight = 0; + const stepHeights = new Map(); let isTransitioning = false; let pendingStep = null; let pendingPushState = false; @@ -183,12 +183,25 @@ } }; - const measureStepHeight = (panel) => { - if (!panel) return; + // Each step is remembered on its own rather than folded into a running maximum. A + // single tall step used to set the floor for every other one, so the short ones -- the + // review in particular -- carried its leftover height as dead space. + const recordStepHeight = (panel, step) => { + if (!panel || step == null) return; const height = panel.getBoundingClientRect().height; if (!height) return; - maxStepHeight = Math.max(maxStepHeight, height); - stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`); + stepHeights.set(Number(step), height); + }; + + const applyStepHeight = (step) => { + const height = stepHeights.get(Number(step)); + if (!height) return; + stepContainer.style.setProperty('--step-min-height', `${height}px`); + }; + + const measureStepHeight = (panel, step) => { + recordStepHeight(panel, step); + applyStepHeight(step); }; const runStepInit = (step, rootElement) => { @@ -237,7 +250,7 @@ panel.innerHTML = html; stepContainer.appendChild(panel); panel.getBoundingClientRect(); - measureStepHeight(panel); + recordStepHeight(panel, step); panel.remove(); }) .catch(() => null); @@ -253,7 +266,7 @@ measurePanel.innerHTML = html; stepContainer.appendChild(measurePanel); measurePanel.getBoundingClientRect(); - measureStepHeight(measurePanel); + recordStepHeight(measurePanel, step); measurePanel.remove(); const newPanel = document.createElement('div'); @@ -265,6 +278,9 @@ newPanel.getBoundingClientRect(); requestAnimationFrame(() => { + // Height and opacity start together, so the card resizes as the step fades + // rather than reflowing the outgoing one first. + applyStepHeight(step); newPanel.classList.remove('is-entering'); newPanel.classList.add('is-active'); if (activePanel) { @@ -440,12 +456,12 @@ } const activePanel = stepContainer.querySelector('.step-panel') || stepContainer; runStepInit(step, activePanel); - measureStepHeight(activePanel); + measureStepHeight(activePanel, step); if (step === 5 && installScreen) { runStepInit(step, installScreen); } const preload = () => { - measureStepHeight(activePanel); + measureStepHeight(activePanel, step); preloadSteps(cardSteps); }; if (document.fonts && document.fonts.ready) { diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index 1ef47f69d8f..507fab89820 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -241,14 +241,24 @@ if (key === 'database') { value = toDatabaseLabel(formState?.database); } - if (key === 'emailCertificates' && !value) { - value = formState?.accountEmail; - } if (value) { node.textContent = value; } }); + // Nothing entered and no account email to borrow: shown as a tag, the way the + // other absent settings on this panel are, rather than an empty row. + const emailNode = root.querySelector('[data-review-value="emailCertificates"]'); + if (emailNode) { + const email = (formState?.emailCertificates || formState?.accountEmail || '').trim(); + emailNode.textContent = email || 'Empty'; + emailNode.classList.toggle('badge', !email); + emailNode.classList.toggle('badge-neutral', !email); + emailNode.classList.toggle('typography-text-xs-400', !email); + emailNode.classList.toggle('typography-text-m-500', Boolean(email)); + emailNode.classList.toggle('text-neutral-primary', Boolean(email)); + } + const badge = root.querySelector('[data-review-badge]'); if (badge) { const hasKey = Boolean((formState?.opensslKey || '').trim()); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 00176bce5c2..a2fb49da7d0 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -486,17 +486,25 @@ let valid = true; const email = root?.querySelector('#account-email'); const password = root?.querySelector('#account-password'); + const emailValue = email?.value.trim() ?? ''; + const passwordValue = password?.value ?? ''; - if (!email || !email.value.trim()) { + // The account is optional -- the installer skips creating one when either + // field is blank, and it can be created from the console afterwards. Half + // an account is still an error, since that reads as an attempt to make one. + if (emailValue === '' && passwordValue === '') { + return true; + } + + if (emailValue === '') { setFieldError?.(email, 'This field is required'); valid = false; - } else if (!isValidEmail?.(email.value.trim())) { + } else if (!isValidEmail?.(emailValue)) { setFieldError?.(email, 'Please enter a valid email address'); valid = false; } - const passwordValue = password?.value ?? ''; - if (!password || !/\S/.test(passwordValue)) { + if (!/\S/.test(passwordValue)) { setFieldError?.(password, 'This field is required'); valid = false; } else if (!isValidPassword?.(passwordValue)) { diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml index f157dad933f..8444baa6629 100644 --- a/app/views/install/installer/templates/steps/step-1.phtml +++ b/app/views/install/installer/templates/steps/step-1.phtml @@ -36,7 +36,7 @@ $assistantOpenAIKeyValue = htmlspecialchars((string) $defaultAssistantOpenAIKey,
- +
- +