From d44820e00c956b5c671c61320951622861acd299 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 15 Apr 2026 14:15:08 +0100 Subject: [PATCH 001/115] Add overwrite and skip options to migration imports Exposes two new optional boolean params on the three migration creation endpoints so CSV / JSON / appwrite-to-appwrite imports can choose how to handle rows whose IDs already exist at the destination. Endpoints updated (app/controllers/api/migrations.php): - POST /v1/migrations/appwrite - POST /v1/migrations/csv/imports - POST /v1/migrations/json/imports Parameter semantics: - overwrite=true -> destination uses upsertDocuments instead of createDocuments; existing rows are replaced with imported values - skip=true -> destination wraps createDocuments in skipDuplicates; existing rows are preserved unchanged, duplicate-id rows silently no-op - both false -> default; fails fast on DuplicateException (original behavior, unchanged) - both true -> overwrite wins (upsert subsumes skip) Both params are stored in the migration Document's options array (matches the existing pattern for destination behavior config like path, size, delimiter, bucketId, etc.) and read back in the worker's processDestination() to instantiate DestinationAppwrite with the new constructor params. Feature-branch note: depends on utopia-php/migration#feat/skip-duplicates (DestinationAppwrite constructor params) which in turn depends on utopia-php/database#852 (skipDuplicates scope guard). composer.json is temporarily pinned to dev-feat/skip-duplicates and dev-csv-import-upsert-v2 respectively; both must be reset to proper release versions once the upstream PRs merge. --- app/controllers/api/migrations.php | 20 +++++- composer.json | 4 +- composer.lock | 72 ++++++++++++-------- src/Appwrite/Platform/Workers/Migrations.php | 2 + 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 4c541d2817..f19598c198 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -87,13 +87,15 @@ Http::post('/v1/migrations/appwrite') ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) ->param('apiKey', '', new Text(512), 'Source API Key') + ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) + ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) ->inject('response') ->inject('dbForProject') ->inject('project') ->inject('platform') ->inject('queueForEvents') ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { + ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, bool $overwrite, bool $skip, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', @@ -109,6 +111,10 @@ Http::post('/v1/migrations/appwrite') 'statusCounters' => '{}', 'resourceData' => '{}', 'errors' => [], + 'options' => [ + 'overwrite' => $overwrite, + 'skip' => $skip, + ], ])); $queueForEvents->setParam('migrationId', $migration->getId()); @@ -352,6 +358,8 @@ Http::post('/v1/migrations/csv/imports') ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) + ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -367,6 +375,8 @@ Http::post('/v1/migrations/csv/imports') string $fileId, string $resourceId, bool $internalFile, + bool $overwrite, + bool $skip, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -467,6 +477,8 @@ Http::post('/v1/migrations/csv/imports') 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'overwrite' => $overwrite, + 'skip' => $skip, ], ])); @@ -656,6 +668,8 @@ Http::post('/v1/migrations/json/imports') ->param('fileId', '', new UID(), 'File ID.') ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) + ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -671,6 +685,8 @@ Http::post('/v1/migrations/json/imports') string $fileId, string $resourceId, bool $internalFile, + bool $overwrite, + bool $skip, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -770,6 +786,8 @@ Http::post('/v1/migrations/json/imports') 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'overwrite' => $overwrite, + 'skip' => $skip, ], ])); diff --git a/composer.json b/composer.json index 3aa6d157cf..2dbe5616e5 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "5.*", + "utopia-php/database": "dev-csv-import-upsert-v2 as 5.99.0", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "1.9.*", + "utopia-php/migration": "dev-feat/skip-duplicates as 1.9.99", "utopia-php/platform": "0.12.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index bc3d9d30bf..e93af8906a 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": "f6a87c1012b316e614258f8f57a28e48", + "content-hash": "0cabf47b85d8fac9a1f78df82b0add1f", "packages": [ { "name": "adhocore/jwt", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.35.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.35.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.35.0" }, "funding": [ { @@ -3132,7 +3132,7 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.35.0" }, "funding": [ { @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "5.3.21", + "version": "dev-csv-import-upsert-v2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d" + "reference": "52b189bded7ef409bb978483a7231d779051b510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/ee2d7d4c87b3a3fae954089ad7494ceb454f619d", - "reference": "ee2d7d4c87b3a3fae954089ad7494ceb454f619d", + "url": "https://api.github.com/repos/utopia-php/database/zipball/52b189bded7ef409bb978483a7231d779051b510", + "reference": "52b189bded7ef409bb978483a7231d779051b510", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.3.21" + "source": "https://github.com/utopia-php/database/tree/csv-import-upsert-v2" }, - "time": "2026-04-10T12:38:57+00:00" + "time": "2026-04-15T10:53:53+00:00" }, { "name": "utopia-php/detector", @@ -4526,16 +4526,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.1", + "version": "dev-feat/skip-duplicates", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" + "reference": "2012cda162ad0ab79678c924d7534de6f3ec85ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/2012cda162ad0ab79678c924d7534de6f3ec85ad", + "reference": "2012cda162ad0ab79678c924d7534de6f3ec85ad", "shasum": "" }, "require": { @@ -4544,7 +4544,7 @@ "ext-openssl": "*", "halaxa/json-machine": "^1.2", "php": ">=8.1", - "utopia-php/database": "5.*", + "utopia-php/database": "dev-csv-import-upsert-v2 as 5.99.0", "utopia-php/dsn": "0.2.*", "utopia-php/storage": "1.0.*" }, @@ -4575,9 +4575,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.1" + "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-03-25T07:05:27+00:00" + "time": "2026-04-15T12:36:51+00:00" }, { "name": "utopia-php/mongo", @@ -7778,7 +7778,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7837,7 +7837,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.35.0" }, "funding": [ { @@ -8028,7 +8028,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.34.0", + "version": "v1.35.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8084,7 +8084,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.34.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.35.0" }, "funding": [ { @@ -8440,9 +8440,25 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/database", + "version": "dev-csv-import-upsert-v2", + "alias": "5.99.0", + "alias_normalized": "5.99.0.0" + }, + { + "package": "utopia-php/migration", + "version": "dev-feat/skip-duplicates", + "alias": "1.9.99", + "alias_normalized": "1.9.99.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/database": 20, + "utopia-php/migration": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 118ff7acf9..710b24c19d 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -276,6 +276,8 @@ class Migrations extends Action $this->dbForProject, $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], + $options['overwrite'] ?? false, + $options['skip'] ?? false, ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, From c5fe71684af7582eb439701540ce50212efc1199 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 15 Apr 2026 14:23:02 +0100 Subject: [PATCH 002/115] Add E2E tests for CSV import overwrite/skip flags Three new test methods in MigrationsBase, following the existing testCreateCSVImport setup pattern: - testCreateCSVImportSkipDuplicates Seeds documents.csv, mutates one row, re-imports with skip=true. Asserts the mutated row keeps its mutated value (not overwritten by the CSV's original value) and the row count stays at 100. - testCreateCSVImportOverwrite Seeds documents.csv, mutates one row, re-imports with overwrite=true. Asserts the mutated row is restored to the CSV's original value (proving upsertDocuments actually replaced the row) and the row count stays at 100. - testCreateCSVImportDefaultFailsOnDuplicate Regression guard: re-imports documents.csv with no flags. Asserts the migration goes to status=failed with errors populated, proving the default duplicate-throws behavior is preserved. All three share a prepareCsvImportFixture() helper that sets up database + table (name, age columns) + bucket + documents.csv upload. Returns the known first-row id + original name/age so tests can mutate and assert on a predictable row. Reuses the existing documents.csv fixture (100 rows with \$id as the first column). No new fixture files needed. --- .../Services/Migrations/MigrationsBase.php | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9e9ce2fbcd..37036cadc2 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1482,6 +1482,250 @@ trait MigrationsBase }, 10_000, 500); } + /** + * Set up a database + table + bucket + uploaded CSV for the skip/overwrite tests. + * Returns [$databaseId, $tableId, $bucketId, $fileId, $firstRowId, $firstRowName, $firstRowAge]. + * + * @return array{string,string,string,string,string,string,int} + */ + private function prepareCsvImportFixture(string $testLabel): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // database + $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'Test DB ' . $testLabel, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $databaseId = $response['body']['$id']; + + // table + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ + 'name' => 'Test table ' . $testLabel, + 'tableId' => ID::unique(), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $tableId = $response['body']['$id']; + + // columns: name, age (match documents.csv fixture) + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + // bucket + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'Bucket ' . $testLabel, + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['csv'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $bucketId = $response['body']['$id']; + + // upload documents.csv (100 rows with $id, name, age columns) + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/documents.csv'), 'text/csv', 'documents.csv'), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $fileId = $response['body']['$id']; + + // first row in documents.csv: hxfcwpcas5xokpwe,Diamond Mendez,56 + return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; + } + + /** + * skip=true on re-import: duplicates are silently no-op'd, existing rows preserved unchanged. + */ + public function testCreateCSVImportSkipDuplicates(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('skip'); + + // First import: 100 rows created + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove skip does NOT overwrite it + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + // Second import with skip=true: no errors, mutated row preserved + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'skip' => true, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Mutated row kept its mutated value (not overwritten by CSV's original age) + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals(22, $row['body']['age'], 'skip=true must not overwrite mutated row'); + + // Row count still 100 (no duplicates created) + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * overwrite=true on re-import: existing rows are replaced with imported values. + */ + public function testCreateCSVImportOverwrite(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareCsvImportFixture('overwrite'); + + // First import: 100 rows created + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove overwrite restores it to the CSV's original value + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + // Second import with overwrite=true: mutated row restored to CSV value + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'overwrite' => true, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Mutated row is back to CSV's original age (proving overwrite actually replaced the row) + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals($originalAge, $row['body']['age'], 'overwrite=true must restore row to imported value'); + + // Row count still 100 (no duplicates created) + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * Default behavior (neither flag): re-import of duplicate ids fails with DuplicateException. + * Regression guard so the skip/overwrite additions don't silently change the default. + */ + public function testCreateCSVImportDefaultFailsOnDuplicate(): void + { + [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareCsvImportFixture('default'); + + // First import: succeeds + $first = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + // Second import with no flags: should fail on duplicate ids + $second = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertNotEmpty($migration['body']['errors']); + }, 60_000, 500); + } + private function performCsvMigration(array $body): array { return $this->client->call(Client::METHOD_POST, '/migrations/csv', [ From 8fa28257bedc2915ec885e92bce4c2797821a0b1 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 10:28:05 +0100 Subject: [PATCH 003/115] Migrations: replace overwrite/skip with onDuplicate enum string, bump database to 5.3.22 --- app/controllers/api/migrations.php | 27 ++++----- composer.json | 2 +- composer.lock | 55 ++++++++----------- src/Appwrite/Platform/Workers/Migrations.php | 3 +- .../Services/Migrations/MigrationsBase.php | 20 +++---- 5 files changed, 46 insertions(+), 61 deletions(-) diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index f19598c198..5868695bd3 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -26,6 +26,7 @@ use Utopia\Database\Validator\Queries\Documents; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Http; +use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite; use Utopia\Migration\Sources\CSV; @@ -87,15 +88,14 @@ Http::post('/v1/migrations/appwrite') ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) ->param('apiKey', '', new Text(512), 'Source API Key') - ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) - ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) + ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('project') ->inject('platform') ->inject('queueForEvents') ->inject('publisherForMigrations') - ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, bool $overwrite, bool $skip, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { + ->action(function (array $resources, string $endpoint, string $projectId, string $apiKey, string $onDuplicate, Response $response, Database $dbForProject, Document $project, array $platform, Event $queueForEvents, MigrationPublisher $publisherForMigrations) { $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', @@ -112,8 +112,7 @@ Http::post('/v1/migrations/appwrite') 'resourceData' => '{}', 'errors' => [], 'options' => [ - 'overwrite' => $overwrite, - 'skip' => $skip, + 'onDuplicate' => $onDuplicate, ], ])); @@ -358,8 +357,7 @@ Http::post('/v1/migrations/csv/imports') ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) - ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) + ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -375,8 +373,7 @@ Http::post('/v1/migrations/csv/imports') string $fileId, string $resourceId, bool $internalFile, - bool $overwrite, - bool $skip, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -477,8 +474,7 @@ Http::post('/v1/migrations/csv/imports') 'options' => [ 'path' => $newPath, 'size' => $fileSize, - 'overwrite' => $overwrite, - 'skip' => $skip, + 'onDuplicate' => $onDuplicate, ], ])); @@ -668,8 +664,7 @@ Http::post('/v1/migrations/json/imports') ->param('fileId', '', new UID(), 'File ID.') ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('overwrite', false, new Boolean(), 'When true, replace existing rows by calling upsertDocuments instead of createDocuments. Rows with matching IDs will be updated with the imported values.', true) - ->param('skip', false, new Boolean(), 'When true, silently ignore rows whose IDs already exist in the destination. Existing rows are preserved unchanged.', true) + ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -685,8 +680,7 @@ Http::post('/v1/migrations/json/imports') string $fileId, string $resourceId, bool $internalFile, - bool $overwrite, - bool $skip, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -786,8 +780,7 @@ Http::post('/v1/migrations/json/imports') 'options' => [ 'path' => $newPath, 'size' => $fileSize, - 'overwrite' => $overwrite, - 'skip' => $skip, + 'onDuplicate' => $onDuplicate, ], ])); diff --git a/composer.json b/composer.json index 2dbe5616e5..d8bb03d120 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "dev-csv-import-upsert-v2 as 5.99.0", + "utopia-php/database": "5.3.22", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", diff --git a/composer.lock b/composer.lock index e93af8906a..de21fbef5e 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": "0cabf47b85d8fac9a1f78df82b0add1f", + "content-hash": "cce51bafc6cbb73585a51e7495b2e6cc", "packages": [ { "name": "adhocore/jwt", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" }, "funding": [ { @@ -3132,7 +3132,7 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" }, "funding": [ { @@ -3850,16 +3850,16 @@ }, { "name": "utopia-php/database", - "version": "dev-csv-import-upsert-v2", + "version": "5.3.22", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "52b189bded7ef409bb978483a7231d779051b510" + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/52b189bded7ef409bb978483a7231d779051b510", - "reference": "52b189bded7ef409bb978483a7231d779051b510", + "url": "https://api.github.com/repos/utopia-php/database/zipball/d765945da6b3141852014b2f96ecf1fe7e3d6ba7", + "reference": "d765945da6b3141852014b2f96ecf1fe7e3d6ba7", "shasum": "" }, "require": { @@ -3903,9 +3903,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/csv-import-upsert-v2" + "source": "https://github.com/utopia-php/database/tree/5.3.22" }, - "time": "2026-04-15T10:53:53+00:00" + "time": "2026-04-20T07:12:46+00:00" }, { "name": "utopia-php/detector", @@ -4530,12 +4530,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "2012cda162ad0ab79678c924d7534de6f3ec85ad" + "reference": "6025318d61c15355015c0e34427480e84ad690ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/2012cda162ad0ab79678c924d7534de6f3ec85ad", - "reference": "2012cda162ad0ab79678c924d7534de6f3ec85ad", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/6025318d61c15355015c0e34427480e84ad690ad", + "reference": "6025318d61c15355015c0e34427480e84ad690ad", "shasum": "" }, "require": { @@ -4544,7 +4544,7 @@ "ext-openssl": "*", "halaxa/json-machine": "^1.2", "php": ">=8.1", - "utopia-php/database": "dev-csv-import-upsert-v2 as 5.99.0", + "utopia-php/database": "5.3.22", "utopia-php/dsn": "0.2.*", "utopia-php/storage": "1.0.*" }, @@ -4577,7 +4577,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-15T12:36:51+00:00" + "time": "2026-04-20T09:07:20+00:00" }, { "name": "utopia-php/mongo", @@ -7778,7 +7778,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7837,7 +7837,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" }, "funding": [ { @@ -8028,7 +8028,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.35.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8084,7 +8084,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.35.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" }, "funding": [ { @@ -8441,12 +8441,6 @@ } ], "aliases": [ - { - "package": "utopia-php/database", - "version": "dev-csv-import-upsert-v2", - "alias": "5.99.0", - "alias_normalized": "5.99.0.0" - }, { "package": "utopia-php/migration", "version": "dev-feat/skip-duplicates", @@ -8456,7 +8450,6 @@ ], "minimum-stability": "dev", "stability-flags": { - "utopia-php/database": 20, "utopia-php/migration": 20 }, "prefer-stable": true, diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 710b24c19d..6a440e54c0 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -276,8 +276,7 @@ class Migrations extends Action $this->dbForProject, $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], - $options['overwrite'] ?? false, - $options['skip'] ?? false, + $options['onDuplicate'] ?? DestinationAppwrite::ON_DUPLICATE_FAIL, ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 37036cadc2..35f196ec49 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1554,7 +1554,7 @@ trait MigrationsBase } /** - * skip=true on re-import: duplicates are silently no-op'd, existing rows preserved unchanged. + * onDuplicate=skip on re-import: duplicates are silently no-op'd, existing rows preserved unchanged. */ public function testCreateCSVImportSkipDuplicates(): void { @@ -1586,12 +1586,12 @@ trait MigrationsBase $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); - // Second import with skip=true: no errors, mutated row preserved + // Second import with onDuplicate=skip: no errors, mutated row preserved $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, - 'skip' => true, + 'onDuplicate' => 'skip', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ @@ -1608,7 +1608,7 @@ trait MigrationsBase ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); - $this->assertEquals(22, $row['body']['age'], 'skip=true must not overwrite mutated row'); + $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); // Row count still 100 (no duplicates created) $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ @@ -1621,7 +1621,7 @@ trait MigrationsBase } /** - * overwrite=true on re-import: existing rows are replaced with imported values. + * onDuplicate=upsert on re-import: existing rows are replaced with imported values. */ public function testCreateCSVImportOverwrite(): void { @@ -1642,7 +1642,7 @@ trait MigrationsBase $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); - // Mutate one row so we can prove overwrite restores it to the CSV's original value + // Mutate one row so we can prove upsert restores it to the CSV's original value $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1653,12 +1653,12 @@ trait MigrationsBase $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); - // Second import with overwrite=true: mutated row restored to CSV value + // Second import with onDuplicate=upsert: mutated row restored to CSV value $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, - 'overwrite' => true, + 'onDuplicate' => 'upsert', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ @@ -1668,14 +1668,14 @@ trait MigrationsBase $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); - // Mutated row is back to CSV's original age (proving overwrite actually replaced the row) + // Mutated row is back to CSV's original age (proving upsert actually replaced the row) $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); - $this->assertEquals($originalAge, $row['body']['age'], 'overwrite=true must restore row to imported value'); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=upsert must restore row to imported value'); // Row count still 100 (no duplicates created) $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ From 18f9dfb64e22c681320b5711038f7e8d007870be Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 13:16:45 +0100 Subject: [PATCH 004/115] Bump utopia-php/migration to 1.9.2, use OnDuplicate enum --- app/controllers/api/migrations.php | 8 +++--- composer.json | 2 +- composer.lock | 29 +++++++------------- src/Appwrite/Platform/Workers/Migrations.php | 3 +- 4 files changed, 17 insertions(+), 25 deletions(-) diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index 5868695bd3..ef71f8f6ef 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -26,7 +26,7 @@ use Utopia\Database\Validator\Queries\Documents; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\UID; use Utopia\Http\Http; -use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite; use Utopia\Migration\Sources\CSV; @@ -88,7 +88,7 @@ Http::post('/v1/migrations/appwrite') ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) ->param('apiKey', '', new Text(512), 'Source API Key') - ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -357,7 +357,7 @@ Http::post('/v1/migrations/csv/imports') ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -664,7 +664,7 @@ Http::post('/v1/migrations/json/imports') ->param('fileId', '', new UID(), 'File ID.') ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('onDuplicate', DestinationAppwrite::ON_DUPLICATE_FAIL, new WhiteList(DestinationAppwrite::ON_DUPLICATES), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') diff --git a/composer.json b/composer.json index d8bb03d120..2f8ea37011 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "dev-feat/skip-duplicates as 1.9.99", + "utopia-php/migration": "1.9.2", "utopia-php/platform": "0.12.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index de21fbef5e..7e09c5cf00 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": "cce51bafc6cbb73585a51e7495b2e6cc", + "content-hash": "8fe5034e4c20ba22c778a666ef451dd9", "packages": [ { "name": "adhocore/jwt", @@ -4526,16 +4526,16 @@ }, { "name": "utopia-php/migration", - "version": "dev-feat/skip-duplicates", + "version": "1.9.2", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "6025318d61c15355015c0e34427480e84ad690ad" + "reference": "97266905f35260137ba0b0e0c4f849f1ee422e43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/6025318d61c15355015c0e34427480e84ad690ad", - "reference": "6025318d61c15355015c0e34427480e84ad690ad", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/97266905f35260137ba0b0e0c4f849f1ee422e43", + "reference": "97266905f35260137ba0b0e0c4f849f1ee422e43", "shasum": "" }, "require": { @@ -4544,7 +4544,7 @@ "ext-openssl": "*", "halaxa/json-machine": "^1.2", "php": ">=8.1", - "utopia-php/database": "5.3.22", + "utopia-php/database": "5.*", "utopia-php/dsn": "0.2.*", "utopia-php/storage": "1.0.*" }, @@ -4575,9 +4575,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" + "source": "https://github.com/utopia-php/migration/tree/1.9.2" }, - "time": "2026-04-20T09:07:20+00:00" + "time": "2026-04-20T11:55:43+00:00" }, { "name": "utopia-php/mongo", @@ -8440,18 +8440,9 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [ - { - "package": "utopia-php/migration", - "version": "dev-feat/skip-duplicates", - "alias": "1.9.99", - "alias_normalized": "1.9.99.0" - } - ], + "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/migration": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 6a440e54c0..d88ab23535 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -30,6 +30,7 @@ use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; use Utopia\Migration\Destinations\JSON as DestinationJSON; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Exception as MigrationException; use Utopia\Migration\Resource; use Utopia\Migration\Resources\Database\Database as ResourceDatabase; @@ -276,7 +277,7 @@ class Migrations extends Action $this->dbForProject, $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], - $options['onDuplicate'] ?? DestinationAppwrite::ON_DUPLICATE_FAIL, + OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail, ), DestinationCSV::getName() => new DestinationCSV( $this->deviceForFiles, From f3c2502a8cec9dd2dfdcf8715303d8c321ea9bfa Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 13:56:25 +0100 Subject: [PATCH 005/115] composer: widen database/migration pins to minor-version wildcards --- composer.json | 4 ++-- composer.lock | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 2f8ea37011..d7681742a5 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,7 @@ "utopia-php/compression": "0.1.*", "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", - "utopia-php/database": "5.3.22", + "utopia-php/database": "5.3.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "1.9.2", + "utopia-php/migration": "1.9.*", "utopia-php/platform": "0.12.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index 7e09c5cf00..423b5bef9c 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": "8fe5034e4c20ba22c778a666ef451dd9", + "content-hash": "86a1fe0eb48da6028c13db50640cc4f6", "packages": [ { "name": "adhocore/jwt", From e878b0b403e73fe7feee9543fb95b16157e0da6f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 14:43:45 +0100 Subject: [PATCH 006/115] tests: cover onDuplicate on JSON import endpoint + column readiness wait --- .../Services/Migrations/MigrationsBase.php | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 35f196ec49..3adff48036 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1528,6 +1528,16 @@ trait MigrationsBase ]); $this->assertEquals(202, $response['headers']['status-code']); + // Columns are created async (202). Wait for both to be `available` + // before proceeding so the migration worker doesn't race the schema. + foreach (['name', 'age'] as $column) { + $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + } + // bucket $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ 'bucketId' => ID::unique(), @@ -1735,6 +1745,246 @@ trait MigrationsBase ], $body); } + /** + * Set up a database + table + bucket + uploaded JSON for the skip/overwrite tests. + * Mirrors prepareCsvImportFixture but uploads documents.json instead. + * + * @return array{string,string,string,string,string,string,int} + */ + private function prepareJsonImportFixture(string $testLabel): array + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // database + $response = $this->client->call(Client::METHOD_POST, '/databases', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'Test JSON DB ' . $testLabel, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $databaseId = $response['body']['$id']; + + // table + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $headers, [ + 'name' => 'Test JSON table ' . $testLabel, + 'tableId' => ID::unique(), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $tableId = $response['body']['$id']; + + // columns: name, age (match documents.json fixture) + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $headers, [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', $headers, [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + $this->assertEquals(202, $response['headers']['status-code']); + + foreach (['name', 'age'] as $column) { + $this->assertEventually(function () use ($databaseId, $tableId, $column, $headers) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/' . $column, $headers); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + } + + // bucket + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'JSON Bucket ' . $testLabel, + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $bucketId = $response['body']['$id']; + + // upload documents.json (same row shape as documents.csv) + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/documents.json'), 'application/json', 'documents.json'), + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $fileId = $response['body']['$id']; + + // first row in documents.json: hxfcwpcas5xokpwe, Diamond Mendez, 56 + return [$databaseId, $tableId, $bucketId, $fileId, 'hxfcwpcas5xokpwe', 'Diamond Mendez', 56]; + } + + /** + * onDuplicate=skip on JSON re-import: duplicates silently no-op, existing rows preserved unchanged. + */ + public function testCreateJSONImportSkipDuplicates(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('skip'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // Mutate one row so we can prove skip does NOT overwrite it + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'skip', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals(22, $row['body']['age'], 'onDuplicate=skip must not overwrite mutated row'); + + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * onDuplicate=upsert on JSON re-import: existing rows replaced with imported values. + */ + public function testCreateJSONImportOverwrite(): void + { + [$databaseId, $tableId, $bucketId, $fileId, $rowId, $originalName, $originalAge] = $this->prepareJsonImportFixture('overwrite'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'data' => ['age' => 22], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals(22, $mutate['body']['age']); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + 'onDuplicate' => 'upsert', + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals($originalName, $row['body']['name']); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=upsert must restore row to imported value'); + + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(150)->toString()], + ]); + $this->assertEquals(100, $rows['body']['total']); + } + + /** + * Default (no onDuplicate) on JSON re-import: regression guard, must fail on duplicate ids. + */ + public function testCreateJSONImportDefaultFailsOnDuplicate(): void + { + [$databaseId, $tableId, $bucketId, $fileId] = $this->prepareJsonImportFixture('default'); + + $first = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($first) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $first['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('completed', $migration['body']['status']); + }, 10_000, 500); + + $second = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $tableId, + ]); + $this->assertEventually(function () use ($second) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertNotEmpty($migration['body']['errors']); + }, 60_000, 500); + } + /** * Test CSV export with email notification */ From fc6bd7232e9105d59bdb584c156b397f9f675740 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 14:50:57 +0100 Subject: [PATCH 007/115] tests: add Appwrite->Appwrite row migration onDuplicate test --- .../Services/Migrations/MigrationsBase.php | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 3adff48036..9b44bc4fd3 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -761,6 +761,96 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Appwrite → Appwrite row migration honoring onDuplicate=skip and onDuplicate=upsert. + * Exercises the row-buffer dispatch path via cross-project migration rather than CSV/JSON upload. + */ + public function testAppwriteMigrationRowsOnDuplicate(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + // Source setup: database + table + column + row + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'Original'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + $rowId = $row['body']['$id']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: destination is empty, all resources copied + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Mutate destination row so we can prove skip preserves it + $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [ + 'data' => ['name' => 'Mutated'], + ]); + $this->assertEquals(200, $mutate['headers']['status-code']); + $this->assertEquals('Mutated', $mutate['body']['name']); + + // Second migration with onDuplicate=skip: destination row must keep 'Mutated' + $second = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $second['status']); + + $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); + $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); + + // Third migration with onDuplicate=upsert: destination row must be restored to 'Original' + $third = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $third['status']); + + $rowAfterUpsert = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfterUpsert['headers']['status-code']); + $this->assertEquals('Original', $rowAfterUpsert['body']['name'], 'onDuplicate=upsert must restore source value'); + + // Cleanup on destination + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + + // Cleanup on source + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From d0603c4d289d356b2eac8f6dea37bac140be28bb Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 16:38:59 +0100 Subject: [PATCH 008/115] tests: add Appwrite->Appwrite row onDuplicate test with tolerant poller --- .../Services/Migrations/MigrationsBase.php | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 9b44bc4fd3..6ba7a0604e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -762,8 +762,12 @@ trait MigrationsBase } /** - * Appwrite → Appwrite row migration honoring onDuplicate=skip and onDuplicate=upsert. - * Exercises the row-buffer dispatch path via cross-project migration rather than CSV/JSON upload. + * Appwrite → Appwrite row re-migration honoring onDuplicate=skip and onDuplicate=upsert. + * + * onDuplicate only gates the row-write path in DestinationAppwrite. Re-running the + * migration with the full resource tree (database/table/column/row) always errors + * on schema creation because destination already has those. This test accepts those + * schema-level errors as expected noise and asserts row-level correctness directly. */ public function testAppwriteMigrationRowsOnDuplicate(): void { @@ -778,7 +782,6 @@ trait MigrationsBase 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]; - // Source setup: database + table + column + row $data = $this->setupMigrationTable(); $databaseId = $data['databaseId']; $tableId = $data['tableId']; @@ -797,7 +800,7 @@ trait MigrationsBase Resource::TYPE_ROW, ]; - // First migration: destination is empty, all resources copied + // First migration: destination is empty, strict completion expected. $first = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, @@ -806,51 +809,80 @@ trait MigrationsBase ]); $this->assertEquals('completed', $first['status']); - // Mutate destination row so we can prove skip preserves it + // Mutate destination row to prove onDuplicate=skip preserves it. $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders, [ 'data' => ['name' => 'Mutated'], ]); $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals('Mutated', $mutate['body']['name']); - // Second migration with onDuplicate=skip: destination row must keep 'Mutated' - $second = $this->performMigrationSync([ + // Re-migration with onDuplicate=skip. Overall status is expected to be 'failed' + // because schema re-create errors (database/table/column already exist) — those + // are orthogonal to onDuplicate which only affects row writes. Assert row-level + // success counter instead. + $this->runMigrationAssertingRowSuccess([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); - $this->assertEquals('completed', $second['status']); $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); - // Third migration with onDuplicate=upsert: destination row must be restored to 'Original' - $third = $this->performMigrationSync([ + // Re-migration with onDuplicate=upsert. Same status-tolerant approach; assert + // destination row was restored to source value. + $this->runMigrationAssertingRowSuccess([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'upsert', ]); - $this->assertEquals('completed', $third['status']); $rowAfterUpsert = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterUpsert['headers']['status-code']); $this->assertEquals('Original', $rowAfterUpsert['body']['name'], 'onDuplicate=upsert must restore source value'); - // Cleanup on destination $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); - - // Cleanup on source $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); self::$cachedDatabaseData = []; self::$cachedTableData = []; } + /** + * Helper for Appwrite→Appwrite re-migrations where onDuplicate applies only to rows. + * Accepts migration stages of 'finished' regardless of overall status, then asserts + * the row-level counter has zero errors (and at least one success). + * + * @param array $body + */ + private function runMigrationAssertingRowSuccess(array $body): void + { + $migration = $this->client->call(Client::METHOD_POST, '/migrations/appwrite', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ], $body); + $this->assertEquals(202, $migration['headers']['status-code']); + + $this->assertEventually(function () use ($migration) { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migration['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('finished', $response['body']['stage']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $response['body']['statusCounters']); + $this->assertEquals(0, $response['body']['statusCounters'][Resource::TYPE_ROW]['error']); + $this->assertGreaterThanOrEqual(1, $response['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 60_000, 500); + } + /** * Storage */ From 19f02a5129507e279d801f2c960ec91f5a554963 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 20 Apr 2026 16:44:36 +0100 Subject: [PATCH 009/115] composer: sync lock content-hash after merge from 1.9.x --- composer.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index 19d7af6719..b56e9b1444 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": "86a1fe0eb48da6028c13db50640cc4f6", + "content-hash": "756f545d9723dbad7f23fd10fddc64bc", "packages": [ { "name": "adhocore/jwt", From 715bd40b4a0c649ab83a0560dce90dcd5d772772 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 22 Apr 2026 16:38:32 +0100 Subject: [PATCH 010/115] =?UTF-8?q?Tighten=20A=E2=86=92A=20re-migration=20?= =?UTF-8?q?tests=20against=20utopia-php/migration=20schema=20tolerance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utopia-php/migration's DestinationAppwrite now handles schema tolerance on re-migration (PR #171 on feat/skip-duplicates): it pre-checks destination `_metadata` for each database / table / column / index and tolerates in Skip/Upsert mode. Re-runs no longer produce schema-level errors, so the E2E tests can drop the status-tolerant workaround and assert strict 'completed' outcomes. Changes: - composer.json: pin utopia-php/migration to dev-feat/skip-duplicates (aliased to 1.9.99 for stability resolution). Will be replaced with a fixed 1.10.0 tag once the migration PR lands. - testAppwriteMigrationRowsOnDuplicate: replace the tolerant runMigrationAssertingRowSuccess helper with performMigrationSync on the Skip and Upsert re-runs. Asserts 'completed' status on every run, destination row content matches the expected value per mode (Mutated preserved on Skip, Original restored on Upsert). Helper method removed. - testAppwriteMigrationReRunIsIdempotent (new): seeds two rows on source, runs the migration three times back-to-back (fresh, Skip re-run, Upsert re-run) against unchanged source data, asserts strict 'completed' on every run and row content is stable across all three. Exercises the schema-tolerance path end-to-end: every database/table/column on destination already exists with a matching spec, so DestinationAppwrite's pre-check returns Tolerate for every resource. --- composer.json | 2 +- composer.lock | 27 ++-- .../Services/Migrations/MigrationsBase.php | 120 +++++++++++++----- 3 files changed, 107 insertions(+), 42 deletions(-) diff --git a/composer.json b/composer.json index bcbd59a636..8daede4537 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "1.9.*", + "utopia-php/migration": "dev-feat/skip-duplicates as 1.9.99", "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index b56e9b1444..9148102adf 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": "756f545d9723dbad7f23fd10fddc64bc", + "content-hash": "b6c60200cc06ec4d8ef8e342f09c7c30", "packages": [ { "name": "adhocore/jwt", @@ -4528,16 +4528,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.2", + "version": "dev-feat/skip-duplicates", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "97266905f35260137ba0b0e0c4f849f1ee422e43" + "reference": "001682168f7d87932c56635a0d0a45b927febc33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/97266905f35260137ba0b0e0c4f849f1ee422e43", - "reference": "97266905f35260137ba0b0e0c4f849f1ee422e43", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/001682168f7d87932c56635a0d0a45b927febc33", + "reference": "001682168f7d87932c56635a0d0a45b927febc33", "shasum": "" }, "require": { @@ -4577,9 +4577,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.2" + "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-20T11:55:43+00:00" + "time": "2026-04-22T13:15:22+00:00" }, { "name": "utopia-php/mongo", @@ -8441,9 +8441,18 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/migration", + "version": "dev-feat/skip-duplicates", + "alias": "1.9.99", + "alias_normalized": "1.9.99.0" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "utopia-php/migration": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 6ba7a0604e..a2199dd63d 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -764,10 +764,11 @@ trait MigrationsBase /** * Appwrite → Appwrite row re-migration honoring onDuplicate=skip and onDuplicate=upsert. * - * onDuplicate only gates the row-write path in DestinationAppwrite. Re-running the - * migration with the full resource tree (database/table/column/row) always errors - * on schema creation because destination already has those. This test accepts those - * schema-level errors as expected noise and asserts row-level correctness directly. + * With utopia-php/migration's DestinationAppwrite handling schema tolerance + * (pre-check the destination `_metadata` for each database / table / column + * / index, tolerate existing in Skip/Upsert), re-migration completes + * cleanly — no more schema-level errors to tolerate. The test asserts + * strict 'completed' status via performMigrationSync on every run. */ public function testAppwriteMigrationRowsOnDuplicate(): void { @@ -816,31 +817,31 @@ trait MigrationsBase $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals('Mutated', $mutate['body']['name']); - // Re-migration with onDuplicate=skip. Overall status is expected to be 'failed' - // because schema re-create errors (database/table/column already exist) — those - // are orthogonal to onDuplicate which only affects row writes. Assert row-level - // success counter instead. - $this->runMigrationAssertingRowSuccess([ + // Re-migration with onDuplicate=skip — completion is strict because + // DestinationAppwrite tolerates existing schema resources. + $skipResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'skip', ]); + $this->assertEquals('completed', $skipResult['status']); $rowAfterSkip = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); - // Re-migration with onDuplicate=upsert. Same status-tolerant approach; assert - // destination row was restored to source value. - $this->runMigrationAssertingRowSuccess([ + // Re-migration with onDuplicate=upsert — strict completion; destination + // row restored to source value. + $upsertResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], 'onDuplicate' => 'upsert', ]); + $this->assertEquals('completed', $upsertResult['status']); $rowAfterUpsert = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfterUpsert['headers']['status-code']); @@ -854,33 +855,88 @@ trait MigrationsBase } /** - * Helper for Appwrite→Appwrite re-migrations where onDuplicate applies only to rows. - * Accepts migration stages of 'finished' regardless of overall status, then asserts - * the row-level counter has zero errors (and at least one success). - * - * @param array $body + * Re-migrating unchanged source (Skip / Upsert) completes cleanly without + * touching destination rows. Proves the schema-tolerance path: every + * database / table / attribute on destination already exists with a + * matching spec, so DestinationAppwrite's pre-check returns Tolerate for + * every resource and no-ops row writes go through the DB-native conflict + * primitives (INSERT IGNORE / ON DUPLICATE KEY UPDATE). */ - private function runMigrationAssertingRowSuccess(array $body): void + public function testAppwriteMigrationReRunIsIdempotent(): void { - $migration = $this->client->call(Client::METHOD_POST, '/migrations/appwrite', [ + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getDestinationProject()['$id'], 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], - ], $body); - $this->assertEquals(202, $migration['headers']['status-code']); + ]; - $this->assertEventually(function () use ($migration) { - $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migration['body']['$id'], [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getDestinationProject()['$id'], - 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + // Seed two rows on source so the row-level tolerance is exercised too. + foreach (['row-a', 'row-b'] as $rowId) { + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'Seeded ' . $rowId], ]); - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals('finished', $response['body']['stage']); - $this->assertArrayHasKey(Resource::TYPE_ROW, $response['body']['statusCounters']); - $this->assertEquals(0, $response['body']['statusCounters'][Resource::TYPE_ROW]['error']); - $this->assertGreaterThanOrEqual(1, $response['body']['statusCounters'][Resource::TYPE_ROW]['success']); - }, 60_000, 500); + $this->assertEquals(201, $row['headers']['status-code']); + } + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: fresh destination. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Re-run under Skip: nothing on source has changed. Destination + // schema + rows are already correct — expect clean completion. + $reRunSkip = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $reRunSkip['status']); + + // Re-run under Upsert: same unchanged source. Schema tolerance path + // fires for each resource; rows go through DB-native upsert. + $reRunUpsert = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $reRunUpsert['status']); + + foreach (['row-a', 'row-b'] as $rowId) { + $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $check['headers']['status-code']); + $this->assertEquals('Seeded ' . $rowId, $check['body']['name']); + } + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; } /** From 2ad95e511382c6ba23762adfc4861c6cb93c1b3f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 22 Apr 2026 16:46:33 +0100 Subject: [PATCH 011/115] ci: restrict to Migrations service for fast feedback loop (TEMP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch is iterating on utopia-php/migration's re-migration tolerance. Other test matrices (unit, general, abuse, screenshots, benchmark, and every other e2e service) add ~30+ minutes to CI without exercising code this PR touches. Restrict the matrix to the Migrations service and skip the unrelated test jobs until the migration work is ready to merge. All jobs marked with 'TEMP:' comments + 'if: false' — revert to the full matrix before merging to main. Static analysis (lint, phpstan, composer audit, specs, locale, security) still runs on every PR push. --- .github/workflows/ci.yml | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b02d021f1a..ce44c19931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,6 +285,8 @@ jobs: unit: name: Tests / Unit + # TEMP: Migrations-only CI run. Re-enable before merging to main. + if: false runs-on: ubuntu-latest needs: build permissions: @@ -332,6 +334,8 @@ jobs: e2e_general: name: Tests / E2E / General + # TEMP: Migrations-only CI run. Re-enable before merging to main. + if: false runs-on: ubuntu-latest needs: build permissions: @@ -400,32 +404,10 @@ jobs: matrix: database: ${{ fromJSON(needs.matrix.outputs.databases) }} mode: ${{ fromJSON(needs.matrix.outputs.modes) }} - service: [ - Account, - Avatars, - Console, - Databases, - TablesDB, - Functions, - FunctionsSchedule, - GraphQL, - Health, - Locale, - Projects, - Realtime, - Sites, - Proxy, - Storage, - Tokens, - Teams, - Users, - ProjectWebhooks, - Webhooks, - VCS, - Messaging, - Migrations, - Project - ] + # TEMP: branch is testing utopia-php/migration re-migration tolerance. + # Restricted to Migrations to get fast CI feedback on the feature work; + # revert to the full matrix before merging to main. + service: [Migrations] include: - service: Databases runner: blacksmith-4vcpu-ubuntu-2404 @@ -524,6 +506,8 @@ jobs: e2e_abuse: name: Tests / E2E / Abuse (${{ matrix.mode }}) + # TEMP: Migrations-only CI run. Re-enable before merging to main. + if: false runs-on: ubuntu-latest needs: [build, matrix] permissions: @@ -583,6 +567,8 @@ jobs: e2e_screenshots: name: Tests / E2E / Screenshots (${{ matrix.mode }}) + # TEMP: Migrations-only CI run. Re-enable before merging to main. + if: false runs-on: ubuntu-latest needs: [build, matrix] permissions: @@ -649,6 +635,8 @@ jobs: benchmark: name: Benchmark + # TEMP: Migrations-only CI run. Re-enable before merging to main. + if: false runs-on: ubuntu-latest needs: build permissions: From c1506643e9196d4395e3ab8c95e83128e3c39376 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 23 Apr 2026 00:12:09 +0100 Subject: [PATCH 012/115] composer: bump utopia-php/migration to cfba224 Picks up the PR #171 refactor + unit tests: - resolveSchemaAction decision point consolidation - deleteAttributeCompletely primitive (two-way cleanup in one place) - Hardened sourceIsNewer against MySQL zero-date sentinel - 14 unit tests locking the decision matrix --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 9148102adf..e590802949 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "001682168f7d87932c56635a0d0a45b927febc33" + "reference": "cfba224f73f00d236a748553d87381a4cf77d0f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/001682168f7d87932c56635a0d0a45b927febc33", - "reference": "001682168f7d87932c56635a0d0a45b927febc33", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/cfba224f73f00d236a748553d87381a4cf77d0f6", + "reference": "cfba224f73f00d236a748553d87381a4cf77d0f6", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-22T13:15:22+00:00" + "time": "2026-04-22T23:09:03+00:00" }, { "name": "utopia-php/mongo", From 44b7b26adfdb86f2c23c71397b02b277e78daa12 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 23 Apr 2026 06:06:30 +0100 Subject: [PATCH 013/115] composer: bump utopia-php/migration to e08d531 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the UpdateInPlace branch — database/table metadata drift is now reconciled on Upsert-newer (renames, enable toggles, table permissions / documentSecurity) via updateDocument, without touching child rows. --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index e590802949..079cb8e145 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "cfba224f73f00d236a748553d87381a4cf77d0f6" + "reference": "e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/cfba224f73f00d236a748553d87381a4cf77d0f6", - "reference": "cfba224f73f00d236a748553d87381a4cf77d0f6", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0", + "reference": "e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-22T23:09:03+00:00" + "time": "2026-04-23T05:05:36+00:00" }, { "name": "utopia-php/mongo", From 0134832fb6f4d0cfe3dec7d6c701e57e2a81f195 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 23 Apr 2026 06:22:26 +0100 Subject: [PATCH 014/115] tests: cover Upsert UpdateInPlace for database/table + Skip preserves dest drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new E2E tests exercising the schema-tolerance UpdateInPlace path added in utopia-php/migration's DestinationAppwrite. testAppwriteMigrationUpsertUpdatesContainerMetadata (positive): - Fresh migration copies source database + table + column + row to dest. - Mutates source database name (PUT /databases/:id) and table name/permissions/rowSecurity/enabled (PUT /tablesdb/:db/tables/:id). - One-second sleep before mutation ensures source's $updatedAt is strictly greater than dest's at second granularity (strtotime comparison). - Upsert re-migration asserts: - 'completed' status. - dest database name matches source's new name. - dest table name / enabled / rowSecurity match source's new values. - child row's 'name' attribute is untouched — UpdateInPlace only rewrites container metadata, not rows. testAppwriteMigrationSkipPreservesContainerDrift (negative): - Fresh migration, then mutate BOTH dest (simulating ops tightening permissions post-migration) and source (divergence). - Skip re-migration asserts dest kept its tightened values — Skip's strict "don't touch" contract protects dev→prod cutover workflows from accidentally wiping ops-side drift on schema re-sync. Both tests use performMigrationSync for strict 'completed' assertions. Runtime ~18s combined. Existing testAppwriteMigrationRowsOnDuplicate and testAppwriteMigrationReRunIsIdempotent regression-tested locally. --- .../Services/Migrations/MigrationsBase.php | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index a2199dd63d..3a997a8354 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -939,6 +939,184 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Upsert re-migration reconciles container metadata drift: when source's + * database or table was modified between runs (rename, enabled flag, + * permissions tightening), the Upsert pre-check returns UpdateInPlace + * and migration's updateDocument propagates source values to dest. + * Children (rows) are preserved. + */ + public function testAppwriteMigrationUpsertUpdatesContainerMetadata(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'persist-me'; + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'SeedRow'], + ]); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration — dest empty, strict completion. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // `_updatedAt` is stored at second granularity (strtotime) — ensure + // the source edits below produce a strictly-newer timestamp than + // dest's first-migration timestamp. + sleep(1); + + // Mutate source: rename database + toggle table enabled. + $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, $sourceHeaders, [ + 'name' => 'Renamed Source DB', + ]); + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ + 'name' => 'Renamed Source Table', + 'permissions' => [Permission::read(Role::any())], + 'rowSecurity' => true, + 'enabled' => false, + ]); + + // Upsert re-migration: UpdateInPlace path fires for database + table. + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + // Assert dest database metadata reflects source's new values. + $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders); + $this->assertEquals(200, $destDb['headers']['status-code']); + $this->assertEquals('Renamed Source DB', $destDb['body']['name']); + + // Assert dest table metadata reflects source's new values. + $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); + $this->assertEquals(200, $destTable['headers']['status-code']); + $this->assertEquals('Renamed Source Table', $destTable['body']['name']); + $this->assertFalse($destTable['body']['enabled'], 'Upsert must propagate source enabled=false'); + $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Upsert must propagate source rowSecurity=true'); + + // Child row untouched — UpdateInPlace only rewrites container metadata. + $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $row['headers']['status-code']); + $this->assertEquals('SeedRow', $row['body']['name'], 'Upsert must not touch child rows when updating container metadata'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** + * Skip mode is strict "don't touch" — destination container metadata + * drift (permissions tightened post-migration, rename on dest) is + * preserved on every re-run, even when source has diverged. Guards + * against a common production workflow: dev→prod migrate, ops tightens + * prod permissions, later schema-only re-sync must not wipe out the + * tightening. + */ + public function testAppwriteMigrationSkipPreservesContainerDrift(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + // First migration: dest gets whatever source had. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Mutate dest: ops tightens permissions and renames the table for + // its production-specific branding. + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders, [ + 'name' => 'Dest-Managed Table', + 'permissions' => [Permission::read(Role::users())], + 'rowSecurity' => false, + 'enabled' => true, + ]); + + // Also mutate source so the second run has a real divergence. + $this->client->call(Client::METHOD_PUT, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $sourceHeaders, [ + 'name' => 'Source Renamed', + 'permissions' => [Permission::read(Role::any())], + 'rowSecurity' => true, + 'enabled' => false, + ]); + + // Skip re-migration: must tolerate existing destination — no update. + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + // Dest kept its tightened values. + $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); + $this->assertEquals(200, $destTable['headers']['status-code']); + $this->assertEquals('Dest-Managed Table', $destTable['body']['name'], 'Skip must not propagate source name over dest drift'); + $this->assertTrue($destTable['body']['enabled'], 'Skip must preserve dest enabled flag'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From 18694e0a28192bfc076779a292499de407ae6885 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 23 Apr 2026 07:10:23 +0100 Subject: [PATCH 015/115] TEMP: run Migrations across all adapters/modes on this branch --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce44c19931..cbc667c767 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,8 +212,11 @@ jobs: const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; const allModes = ['dedicated', 'shared']; - const defaultDatabases = ['MongoDB']; - const defaultModes = ['dedicated']; + // TEMP: branch is testing utopia-php/migration re-migration tolerance + // across every adapter/mode. Revert to ['MongoDB']/['dedicated'] + // before merging to main. + const defaultDatabases = allDatabases; + const defaultModes = allModes; const pr = context.payload.pull_request; if (!pr) { From 84b6dfa9d5050b072e33ffbe2700f28d8ff4000f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 23 Apr 2026 16:39:30 +0100 Subject: [PATCH 016/115] composer: bump utopia-php/migration to bb21912 (SKIPPED status on tolerate) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 079cb8e145..4a005bb20f 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0" + "reference": "bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0", - "reference": "e08d531d7cab9c6aeed4fa28cfe326449b0dd3b0", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1", + "reference": "bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-23T05:05:36+00:00" + "time": "2026-04-23T15:35:27+00:00" }, { "name": "utopia-php/mongo", From e728265d21525110fa4104a3e6ba9d40472ac077 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 24 Apr 2026 05:57:32 +0100 Subject: [PATCH 017/115] composer: bump utopia-php/migration to 36fdf26 (Upsert orphan cleanup + nullable timestamps) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 4a005bb20f..8beec7f2fe 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1" + "reference": "36fdf269fe1c6fac2a1d80f5b913493c18989727" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1", - "reference": "bb21912e8cb501e5144b6bc6e1d0c9a6918a88d1", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/36fdf269fe1c6fac2a1d80f5b913493c18989727", + "reference": "36fdf269fe1c6fac2a1d80f5b913493c18989727", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-23T15:35:27+00:00" + "time": "2026-04-24T04:55:29+00:00" }, { "name": "utopia-php/mongo", From 8292f7493d85f4e3b45693e4eaea26eca33b5f6a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 24 Apr 2026 07:25:37 +0100 Subject: [PATCH 018/115] composer: bump utopia-php/migration to c2b8715 (rename orphan tracker to 'table' vocabulary) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 8beec7f2fe..31c3610ad0 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "36fdf269fe1c6fac2a1d80f5b913493c18989727" + "reference": "c2b871519bbdaf7b3a2d947baa4e88a4b407c85d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/36fdf269fe1c6fac2a1d80f5b913493c18989727", - "reference": "36fdf269fe1c6fac2a1d80f5b913493c18989727", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/c2b871519bbdaf7b3a2d947baa4e88a4b407c85d", + "reference": "c2b871519bbdaf7b3a2d947baa4e88a4b407c85d", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-24T04:55:29+00:00" + "time": "2026-04-24T06:24:44+00:00" }, { "name": "utopia-php/mongo", From 478a2a6e86eb12cae084dbac30b1397d8afadb79 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 24 Apr 2026 07:30:29 +0100 Subject: [PATCH 019/115] tests: Upsert drops orphan columns / Skip preserves them testAppwriteMigrationUpsertDropsOrphanColumn: adds a column directly on destination (simulating post-rename orphan or dest-only drift), runs Upsert, asserts the orphan is dropped and source-declared column survives. Covers the per-table orphan cleanup fired inside createRecord before rows land. testAppwriteMigrationSkipKeepsOrphanColumn: same setup, Skip mode. Asserts the orphan survives, proving the cleanup is correctly gated to Upsert only. --- .../Services/Migrations/MigrationsBase.php | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 7a8f918b96..229c01057e 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1117,6 +1117,180 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Upsert re-migration reconciles column-level drift: a column that + * exists only on destination (e.g. from a subsequent dest-side edit, or + * left over after a source-side rename) must be dropped so destination's + * schema matches what source declares. Source-declared columns are + * preserved. Rows land after the orphan drop so row upsert doesn't + * fail on orphan required columns. + */ + public function testAppwriteMigrationUpsertDropsOrphanColumn(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration: dest mirrors source (one column 'name'). + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Add an orphan column directly on destination (not on source). + // Simulates the post-rename state: source dropped a column, dest + // still has it — or a dest-only column added by a separate app. + $orphanResp = $this->client->call( + Client::METHOD_POST, + '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', + $destHeaders, + [ + 'key' => 'orphan_col', + 'size' => 50, + 'required' => false, + ] + ); + $this->assertEquals(202, $orphanResp['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 5000, 500); + + // Seed a row on source so per-table orphan cleanup fires inside + // createRecord (before rows land), not just at end of run. + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'seed'], + ]); + + // Upsert re-migration: orphan_col must be dropped from dest. + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + // Orphan column dropped. + $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); + $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Upsert must drop destination column source no longer declares'); + + // Source's column preserved. + $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Upsert must preserve columns source declared'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** + * Skip mode never touches destination, including orphan columns. + * Pairs with testAppwriteMigrationUpsertDropsOrphanColumn to prove the + * cleanup is gated correctly: only Upsert reconciles, Skip tolerates. + */ + public function testAppwriteMigrationSkipKeepsOrphanColumn(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $orphanResp = $this->client->call( + Client::METHOD_POST, + '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', + $destHeaders, + [ + 'key' => 'dest_only_col', + 'size' => 50, + 'required' => false, + ] + ); + $this->assertEquals(202, $orphanResp['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 5000, 500); + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => ID::unique(), + 'data' => ['name' => 'seed'], + ]); + + // Skip re-migration: orphan column must NOT be dropped. + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/dest_only_col', $destHeaders); + $this->assertEquals(200, $orphanCheck['headers']['status-code'], 'Skip must preserve destination columns, including orphans'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From dc9f48c9485f0dedc0478307d23a69964b839874 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Fri, 24 Apr 2026 15:09:42 +0100 Subject: [PATCH 020/115] composer: bump utopia-php/migration to 6e6f825 (relationships UpdateInPlace) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 31c3610ad0..6ebcae42c5 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c2b871519bbdaf7b3a2d947baa4e88a4b407c85d" + "reference": "6e6f8255fa52c8053fa2b9ad877c7797d93e32c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c2b871519bbdaf7b3a2d947baa4e88a4b407c85d", - "reference": "c2b871519bbdaf7b3a2d947baa4e88a4b407c85d", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/6e6f8255fa52c8053fa2b9ad877c7797d93e32c5", + "reference": "6e6f8255fa52c8053fa2b9ad877c7797d93e32c5", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-24T06:24:44+00:00" + "time": "2026-04-24T10:33:58+00:00" }, { "name": "utopia-php/mongo", From 5f8a32798dd6cf0c0e756c0861eb48b1ac100a16 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 27 Apr 2026 12:32:37 +0100 Subject: [PATCH 021/115] tests: SDK-aligned UpdateInPlace coverage + bump migration to a36d95f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new e2e tests in MigrationsBase covering the schema reconciliation paths added in utopia-php/migration: - testAppwriteMigrationUpsertUpdatesAttributeInPlace: PATCH source required/default (SDK-reachable), assert dest reflects change and the pre-existing row's column data is preserved (drop+recreate would have wiped it). - testAppwriteMigrationSkipPreservesAttributeDrift: leaf-level analog of the existing container-drift Skip test — guards Skip from ever consulting timestamps. - testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace: PATCH source onDelete cascade->restrict (SDK-reachable), assert dest reflects change and structural fields (relationType, twoWay) untouched. composer.lock: utopia-php/migration 6e6f825 -> a36d95f (mechanical helpers replacement, parseTimestamp dedup, match dispatch, comment trim). --- composer.lock | 8 +- .../Services/Migrations/MigrationsBase.php | 316 ++++++++++++++++++ 2 files changed, 320 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 6ebcae42c5..084d4a5bfe 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "6e6f8255fa52c8053fa2b9ad877c7797d93e32c5" + "reference": "a36d95f86d24b4f024a419191e69c3c034a593eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/6e6f8255fa52c8053fa2b9ad877c7797d93e32c5", - "reference": "6e6f8255fa52c8053fa2b9ad877c7797d93e32c5", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/a36d95f86d24b4f024a419191e69c3c034a593eb", + "reference": "a36d95f86d24b4f024a419191e69c3c034a593eb", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-24T10:33:58+00:00" + "time": "2026-04-27T11:29:25+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 229c01057e..264f6a0ee1 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1291,6 +1291,322 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Upsert reconciles attribute-level metadata edits using the Appwrite SDK's + * per-type updateXAttribute endpoint instead of drop+recreate. Source-side + * PATCH of fields the SDK can express (`required`, `default`, `size` for + * strings) — same `$createdAt` on both sides, source `$updatedAt` newer — + * routes through `updateAttributeInPlace` on DestinationAppwrite. Existing + * row data must survive (drop+recreate would have wiped the column). + */ + public function testAppwriteMigrationUpsertUpdatesAttributeInPlace(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'persist-on-inplace'; + + // Seed a row that proves drop+recreate didn't happen — recreate would + // have wiped this column's data on the destination. + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'SeedRow'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + // First migration — dest gets the column as required:true. + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $beforeUpdate = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $beforeUpdate['headers']['status-code']); + $this->assertTrue($beforeUpdate['body']['required']); + + // _updatedAt has second granularity; ensure source's PATCH produces a + // strictly-newer timestamp than the dest's first-migration value. + sleep(1); + + // SDK-reachable change set: required true→false, default null→'unknown'. + // Both fields are supported by PATCH /columns/string/:key — must route + // through updateAttributeInPlace, not DropAndRecreate. + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ + 'required' => false, + 'default' => 'unknown', + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required']); + $this->assertEquals('unknown', $r['body']['default']); + }, 5000, 500); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required'], 'updateAttributeInPlace must propagate source required=false'); + $this->assertEquals('unknown', $r['body']['default'], 'updateAttributeInPlace must propagate source default'); + }, 10000, 500); + + // Pre-existing row preserved — proof that the path was UpdateInPlace + // and not DropAndRecreate (which would have nulled this column). + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals('SeedRow', $rowAfter['body']['name'], 'updateAttributeInPlace must not touch row data'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** + * Skip mode is "don't touch" at the attribute level too: destination + * column drift (ops loosened a column post-migration) must survive a + * Skip re-run, even when source's `$updatedAt` is strictly newer. + * Pairs with testAppwriteMigrationSkipPreservesContainerDrift but + * exercises the leaf path (`canDrop = true`) instead of the container + * path. Regression guard against Skip ever consulting timestamps. + */ + public function testAppwriteMigrationSkipPreservesAttributeDrift(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Dest divergence: ops loosens the column for a production-only need. + $destPatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $destHeaders, [ + 'required' => false, + 'default' => 'dest-default', + ]); + $this->assertEquals(200, $destPatch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertFalse($r['body']['required']); + }, 5000, 500); + + sleep(1); + + // Source advances strictly later (and to a different value). Under + // Upsert this would propagate to dest; under Skip it must not. + $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ + 'required' => true, + 'default' => null, + ]); + $this->assertEquals(200, $sourcePatch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertTrue($r['body']['required']); + }, 5000, 500); + + $skipResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'skip', + ]); + $this->assertEquals('completed', $skipResult['status']); + + $destAttr = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destAttr['headers']['status-code']); + $this->assertFalse($destAttr['body']['required'], 'Skip must not propagate source required over dest drift'); + $this->assertEquals('dest-default', $destAttr['body']['default'], 'Skip must preserve dest default'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** + * Upsert reconciles relationship `onDelete` drift through the SDK's + * `updateRelationshipAttribute` endpoint — the only relationship fields + * the SDK exposes for in-place edit are `onDelete` and `newKey`. Any + * structural change (`relationType`, `twoWay`, `twoWayKey`, + * `relatedCollection`) is a non-SDK field and must drop+recreate via + * `deleteRelationship`. This test exercises the in-place path: change + * `onDelete` cascade→restrict on source, re-migrate Upsert, assert dest + * reflects the new value without dropping the column. + */ + public function testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'Rel In-Place DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + // One-way relationship parents → children. One-way is sufficient to + // exercise updateRelationshipInPlace; two-way pair-key dedup is + // covered by the existing two-way coverage in MigrationDocumentsDB. + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => false, + 'key' => 'kids', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + }, 10000, 500); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + }, 10000, 500); + + sleep(1); + + // SDK-reachable: PATCH /columns/:key/relationship accepts onDelete. + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ + 'onDelete' => Database::RELATION_MUTATE_RESTRICT, + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); + }, 5000, 500); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'updateRelationshipInPlace must propagate source onDelete'); + $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'In-place update must not change relationType'); + $this->assertFalse($r['body']['twoWay'], 'In-place update must not change twoWay'); + }, 10000, 500); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From dfde1be03544301365f18450495c362ed4976928 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 27 Apr 2026 14:03:02 +0100 Subject: [PATCH 022/115] tests: cover two-way relationship onDelete in-place update + bump migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version of this test created a one-way relationship, which falls through to DropAndRecreate (one-way + onDelete change is gated off in updateRelationshipInPlace because utopia's updateRelationship partner-cascade throws on one-way). It never exercised the in-place path it was named for. Converted to two-way (parents.kids ↔ children.parent), and asserted both parent- and partner-side onDelete on dest. Partner-side assertion is the regression guard for the partner-meta refresh that was missing from updateRelationshipInPlace. composer.lock: utopia-php/migration a36d95f -> c76de9a (partner-side onDelete sync fix). --- composer.lock | 8 +-- .../Services/Migrations/MigrationsBase.php | 58 ++++++++++++------- 2 files changed, 40 insertions(+), 26 deletions(-) diff --git a/composer.lock b/composer.lock index 084d4a5bfe..9f951576fb 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "a36d95f86d24b4f024a419191e69c3c034a593eb" + "reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/a36d95f86d24b4f024a419191e69c3c034a593eb", - "reference": "a36d95f86d24b4f024a419191e69c3c034a593eb", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/c76de9acffa7fddac7cb1e969ebde63c053abcd3", + "reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-27T11:29:25+00:00" + "time": "2026-04-27T12:57:33+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 264f6a0ee1..c52cd8fb99 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1490,14 +1490,15 @@ trait MigrationsBase } /** - * Upsert reconciles relationship `onDelete` drift through the SDK's - * `updateRelationshipAttribute` endpoint — the only relationship fields - * the SDK exposes for in-place edit are `onDelete` and `newKey`. Any - * structural change (`relationType`, `twoWay`, `twoWayKey`, - * `relatedCollection`) is a non-SDK field and must drop+recreate via - * `deleteRelationship`. This test exercises the in-place path: change - * `onDelete` cascade→restrict on source, re-migrate Upsert, assert dest - * reflects the new value without dropping the column. + * Upsert reconciles two-way relationship `onDelete` drift via the SDK's + * updateRelationshipAttribute (only `onDelete`/`newKey` are SDK-reachable). + * Two-way is required to exercise updateRelationshipInPlace — one-way + + * onDelete change falls through to DropAndRecreate (utopia's + * updateRelationship partner-cascade throws on one-way). + * + * Asserts both sides of the relationship reflect the new onDelete on dest: + * utopia's updateRelationship syncs the physical constraint on both sides, + * but the Appwrite-level partner meta doc has to be refreshed explicitly. */ public function testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace(): void { @@ -1527,14 +1528,13 @@ trait MigrationsBase $this->assertEquals(201, $createTable['headers']['status-code']); } - // One-way relationship parents → children. One-way is sufficient to - // exercise updateRelationshipInPlace; two-way pair-key dedup is - // covered by the existing two-way coverage in MigrationDocumentsDB. + // Two-way: parents.kids ↔ children.parent. Required to hit the in-place path. $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, - 'twoWay' => false, + 'twoWay' => true, 'key' => 'kids', + 'twoWayKey' => 'parent', 'onDelete' => Database::RELATION_MUTATE_CASCADE, ]); $this->assertEquals(202, $createRel['headers']['status-code']); @@ -1560,11 +1560,17 @@ trait MigrationsBase ]); $this->assertEquals('completed', $first['status']); + // Both sides land on dest with onDelete=cascade. $this->assertEventually(function () use ($databaseId, $destHeaders) { - $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); - $this->assertEquals(200, $r['headers']['status-code']); - $this->assertEquals('available', $r['body']['status']); - $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $parent['body']['onDelete']); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $child['body']['onDelete']); }, 10000, 500); sleep(1); @@ -1591,13 +1597,21 @@ trait MigrationsBase ]); $this->assertEquals('completed', $upsertResult['status']); + // Both sides on dest must reflect onDelete=restrict. Asserting the + // partner side is the regression guard for the previously-missed + // partner meta refresh in updateRelationshipInPlace. $this->assertEventually(function () use ($databaseId, $destHeaders) { - $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); - $this->assertEquals(200, $r['headers']['status-code']); - $this->assertEquals('available', $r['body']['status']); - $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'updateRelationshipInPlace must propagate source onDelete'); - $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'In-place update must not change relationType'); - $this->assertFalse($r['body']['twoWay'], 'In-place update must not change twoWay'); + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $parent['body']['onDelete'], 'parent-side onDelete must reflect source'); + $this->assertEquals(Database::RELATION_ONE_TO_MANY, $parent['body']['relationType'], 'In-place update must not change relationType'); + $this->assertTrue($parent['body']['twoWay'], 'In-place update must not change twoWay'); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $child['body']['onDelete'], 'partner-side onDelete must reflect source after in-place update'); }, 10000, 500); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); From c80c747e4896af8d1f2bfc4bca5bf3ff693909b0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 27 Apr 2026 14:45:39 +0100 Subject: [PATCH 023/115] tests: pin two-way recreate partner-side dedup + bump migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide exercises the DropAndRecreate path on a two-way relationship that the partner- side pair-key dedup guards. Source recreates the relationship between runs, forcing parent-side createdAt diff. Test asserts the migration completes cleanly and partner-table rows survive — without dedup, the partner pass re-fires DropAndRecreate and destroys those rows. composer.lock: utopia-php/migration c76de9a -> c13e77d (partner-side pair-key dedup restored). --- composer.lock | 8 +- .../Services/Migrations/MigrationsBase.php | 154 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 9f951576fb..2a8c2cd5be 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3" + "reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c76de9acffa7fddac7cb1e969ebde63c053abcd3", - "reference": "c76de9acffa7fddac7cb1e969ebde63c053abcd3", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/c13e77d562e775b66ab8c03ee5c8163e53c3067e", + "reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-27T12:57:33+00:00" + "time": "2026-04-27T13:42:58+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index c52cd8fb99..64ff4af596 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1621,6 +1621,160 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Two-way DropAndRecreate path: source recreates the relationship between + * runs (createdAt diff). Without partner-side pair-key dedup, the second + * createField pass for the partner can re-fire DropAndRecreate after the + * first side already reconciled both physical columns, destroying rows + * already migrated to the partner table this run. + * + * Asserts: migration completes, both sides exist on dest, child rows + * referencing the parent are preserved end-to-end. + */ + public function testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'Two-Way Recreate DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => true, + 'key' => 'kids', + 'twoWayKey' => 'parent', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [ + 'rowId' => 'parent-1', + 'data' => [], + ]); + $this->assertEquals(201, $parentRow['headers']['status-code']); + $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [ + 'rowId' => 'child-1', + 'data' => ['parent' => 'parent-1'], + ]); + $this->assertEquals(201, $childRow['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + // Recreate the relationship on source so its createdAt advances past + // dest's stored value — forces SchemaAction::DropAndRecreate on the + // parent side, which is the path the partner-side dedup guards. + sleep(1); + $deleteRel = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(204, $deleteRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + sleep(1); + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => true, + 'key' => 'kids', + 'twoWayKey' => 'parent', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + // Child-row's relationship was wiped by the source-side delete. Re-link. + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $sourceHeaders, [ + 'data' => ['parent' => 'parent-1'], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $parent['headers']['status-code']); + $this->assertEquals('available', $parent['body']['status']); + + $child = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/columns/parent', $destHeaders); + $this->assertEquals(200, $child['headers']['status-code']); + $this->assertEquals('available', $child['body']['status']); + }, 10000, 500); + + // Both rows survive the re-migration. If the partner-side dedup were + // missing and the partner pass re-fired DropAndRecreate, the partner + // (children) table's row would have been wiped before the row pass. + $destChild = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/children/rows/child-1', $destHeaders); + $this->assertEquals(200, $destChild['headers']['status-code'], 'partner-table row must survive two-way recreate re-migration'); + $this->assertEquals('parent-1', $destChild['body']['parent']['$id'] ?? $destChild['body']['parent'], 'partner-table row relationship must point to the migrated parent'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From 7b32fd0196ecfd2f116062c0068dde231febad4d Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 27 Apr 2026 17:32:14 +0100 Subject: [PATCH 024/115] composer: bump utopia-php/migration to 09c1b21 (maintainability pass) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 2a8c2cd5be..a5e5d52d35 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e" + "reference": "09c1b2133d3ec42d11aee345f003d067d1de9ae5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c13e77d562e775b66ab8c03ee5c8163e53c3067e", - "reference": "c13e77d562e775b66ab8c03ee5c8163e53c3067e", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/09c1b2133d3ec42d11aee345f003d067d1de9ae5", + "reference": "09c1b2133d3ec42d11aee345f003d067d1de9ae5", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-27T13:42:58+00:00" + "time": "2026-04-27T16:27:12+00:00" }, { "name": "utopia-php/mongo", From b03c901fa758504691691c85a64a8738ec9ba234 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 27 Apr 2026 17:40:23 +0100 Subject: [PATCH 025/115] tests: cover one-way DropAndRecreate + attribute-recreate scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coverage gaps closed: - testAppwriteMigrationUpsertOneWayRelationshipDropAndRecreate exercises the path that updateRelationshipInPlace gates off: one-way + onDelete change → returns false → falls through to DropAndRecreate via deleteRelationship. Coverage was lost when testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace was converted to two-way to actually hit the in-place path. - testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates pins the createdAt-different leaf path: source drops + recreates the attribute (createdAt advances), re-migration must DropAndRecreate on dest and re-flow the row data through the row pass. Companion to testAppwriteMigrationUpsertUpdatesAttributeInPlace which covers the same-createdAt + newer-updatedAt path. Migration package already at 09c1b21 (the maintainability commit) from the previous lock bump — no further composer.lock change needed. --- .../Services/Migrations/MigrationsBase.php | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 64ff4af596..cc399cc768 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1775,6 +1775,221 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * One-way + onDelete change is gated to false in updateRelationshipInPlace + * (utopia's updateRelationship partner-cascade throws on one-way), so the + * caller falls through to DropAndRecreate via deleteRelationship. Asserts + * dest's onDelete reflects source's new value end-to-end. Companion to + * testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace which + * exercises the two-way in-place path. + */ + public function testAppwriteMigrationUpsertOneWayRelationshipDropAndRecreate(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $databaseId = ID::unique(); + $createDb = $this->client->call(Client::METHOD_POST, '/databases', $sourceHeaders, [ + 'databaseId' => $databaseId, + 'name' => 'One-Way DropAndRecreate DB', + ]); + $this->assertEquals(201, $createDb['headers']['status-code']); + + foreach (['parents', 'children'] as $tbl) { + $createTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', $sourceHeaders, [ + 'tableId' => $tbl, + 'name' => $tbl, + ]); + $this->assertEquals(201, $createTable['headers']['status-code']); + } + + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ + 'relatedTableId' => 'children', + 'type' => Database::RELATION_ONE_TO_MANY, + 'twoWay' => false, + 'key' => 'kids', + 'onDelete' => Database::RELATION_MUTATE_CASCADE, + ]); + $this->assertEquals(202, $createRel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_CASCADE, $r['body']['onDelete']); + }, 10000, 500); + + sleep(1); + + $patch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids/relationship', $sourceHeaders, [ + 'onDelete' => Database::RELATION_MUTATE_RESTRICT, + ]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $sourceHeaders); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); + }, 5000, 500); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + $this->assertEventually(function () use ($databaseId, $destHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete'], 'one-way DropAndRecreate must propagate source onDelete'); + $this->assertEquals(Database::RELATION_ONE_TO_MANY, $r['body']['relationType'], 'DropAndRecreate must preserve relationType'); + $this->assertFalse($r['body']['twoWay'], 'DropAndRecreate must preserve twoWay=false'); + }, 10000, 500); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + + /** + * Source drops + recreates a regular attribute between runs (createdAt + * differs, leaf, canDrop=true). Re-migration must DropAndRecreate the + * destination attribute and re-flow the row data — proving the + * createdAt-aware decision distinguishes "physically recreated" from + * "metadata edit". Pairs with testAppwriteMigrationUpsertUpdatesAttributeInPlace + * which exercises the same-createdAt + newer-updatedAt path. + */ + public function testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'row-after-recreate'; + + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'before-recreate'], + ]); + $this->assertEquals(201, $row['headers']['status-code']); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + sleep(1); + + // Drop + recreate the column on source. createdAt advances → re-migration + // must take the createdAt-diff DropAndRecreate path on dest. + $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(204, $delete['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ + 'key' => 'name', + 'size' => 100, + 'required' => false, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + // Source row's data was nulled by the source-side delete. Set fresh value. + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ + 'data' => ['name' => 'after-recreate'], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { + $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $col['headers']['status-code']); + $this->assertEquals('available', $col['body']['status']); + $this->assertFalse($col['body']['required'], 'recreated column must reflect the new spec (required=false)'); + }, 10000, 500); + + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals('after-recreate', $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value'); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From 8cccc7993580120c3b13d2dffd3f38c35f2d7d89 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 03:31:25 +0100 Subject: [PATCH 026/115] composer: bump utopia-php/migration to 47933c1 (inline TwoWayPartner) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index a5e5d52d35..f3aa60fb2e 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "09c1b2133d3ec42d11aee345f003d067d1de9ae5" + "reference": "47933c1dd01173d7bda38572593f28a68a740e45" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/09c1b2133d3ec42d11aee345f003d067d1de9ae5", - "reference": "09c1b2133d3ec42d11aee345f003d067d1de9ae5", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/47933c1dd01173d7bda38572593f28a68a740e45", + "reference": "47933c1dd01173d7bda38572593f28a68a740e45", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-27T16:27:12+00:00" + "time": "2026-04-28T02:30:55+00:00" }, { "name": "utopia-php/mongo", From ac46ff802a1d34951b252d66a0a1806de04da5a9 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 03:45:25 +0100 Subject: [PATCH 027/115] composer: bump utopia-php/migration to 24fd23b (SDK-boundary lock-in test) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index f3aa60fb2e..ebce2f687b 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "47933c1dd01173d7bda38572593f28a68a740e45" + "reference": "24fd23b676ee7c7163e583c8559f362471940954" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/47933c1dd01173d7bda38572593f28a68a740e45", - "reference": "47933c1dd01173d7bda38572593f28a68a740e45", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/24fd23b676ee7c7163e583c8559f362471940954", + "reference": "24fd23b676ee7c7163e583c8559f362471940954", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T02:30:55+00:00" + "time": "2026-04-28T02:44:46+00:00" }, { "name": "utopia-php/mongo", From 3fba7afd2e4afe2a3f98e09f7e5adcb10926da2d Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 04:16:51 +0100 Subject: [PATCH 028/115] tests: pin spec-match guard + bump migration to c8d1789 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testAppwriteMigrationUpsertSameSpecRecreateTolerates exercises the new spec-match guard added in utopia-php/migration c8d1789. Source drops + recreates a column with the EXACT same spec as before; createdAt advances but specs match → action is forced to Tolerate. Asserts dest column's $createdAt stays at first-migration value (proving Tolerate, not DropAndRecreate). Row pass under Upsert still propagates source's new row value. Companion to testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates which exercises the spec-DIFFERS path: same precondition (drop + recreate), different outcome (DropAndRecreate vs Tolerate) gated on spec equality. composer.lock: utopia-php/migration 24fd23b -> c8d1789 (spec-match guard). --- composer.lock | 8 +- .../Services/Migrations/MigrationsBase.php | 114 ++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index ebce2f687b..15a7039b6d 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "24fd23b676ee7c7163e583c8559f362471940954" + "reference": "c8d17898458de4a297ba03fd1a126f7aa13227af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/24fd23b676ee7c7163e583c8559f362471940954", - "reference": "24fd23b676ee7c7163e583c8559f362471940954", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/c8d17898458de4a297ba03fd1a126f7aa13227af", + "reference": "c8d17898458de4a297ba03fd1a126f7aa13227af", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T02:44:46+00:00" + "time": "2026-04-28T03:12:58+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index cc399cc768..b38da99554 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1990,6 +1990,120 @@ trait MigrationsBase self::$cachedTableData = []; } + /** + * Source drops + recreates a column with the EXACT same spec. createdAt + * advances on source, but the spec-match guard short-circuits the + * DropAndRecreate to Tolerate — dest's column meta doc stays untouched + * (verified via $createdAt invariance). Row pass under Upsert still + * propagates source's new row values via upsertDocuments. + * + * Companion to testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates + * which exercises the spec-DIFFERS path: same precondition, different + * outcome based on whether spec matches. + */ + public function testAppwriteMigrationUpsertSameSpecRecreateTolerates(): void + { + $sourceHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $destHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]; + + $data = $this->setupMigrationTable(); + $databaseId = $data['databaseId']; + $tableId = $data['tableId']; + $rowId = 'row-spec-match'; + + $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', $sourceHeaders, [ + 'rowId' => $rowId, + 'data' => ['name' => 'before-recreate'], + ]); + + $resources = [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + ]; + + $first = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $first['status']); + + $destBefore = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destBefore['headers']['status-code']); + $destCreatedAtBefore = $destBefore['body']['$createdAt']; + + sleep(1); + + // Drop + recreate with the EXACT same spec as setupMigrationTable + // (size=100, required=true). Source's $createdAt advances but the + // spec is identical → spec-match guard must force Tolerate. + $delete = $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(204, $delete['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(404, $r['headers']['status-code']); + }, 10000, 500); + + $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ + 'key' => 'name', + 'size' => 100, + 'required' => true, + ]); + $this->assertEquals(202, $recreate['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $tableId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + + $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ + 'data' => ['name' => 'after-recreate'], + ]); + $this->assertEquals(200, $relink['headers']['status-code']); + + $upsertResult = $this->performMigrationSync([ + 'resources' => $resources, + 'endpoint' => $this->webEndpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + 'onDuplicate' => 'upsert', + ]); + $this->assertEquals('completed', $upsertResult['status']); + + // Spec-match guard fired → dest column's $createdAt stayed at the + // first-migration value. If DropAndRecreate had run, $createdAt + // would have been bumped to source's NEW createdAt. + $destAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); + $this->assertEquals(200, $destAfter['headers']['status-code']); + $this->assertEquals($destCreatedAtBefore, $destAfter['body']['$createdAt'], 'spec-match guard must keep dest column meta untouched'); + $this->assertEquals(100, $destAfter['body']['size']); + $this->assertTrue($destAfter['body']['required']); + + // Row pass under Upsert still propagated source's new row value. + $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfter['headers']['status-code']); + $this->assertEquals('after-recreate', $rowAfter['body']['name']); + + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); + + self::$cachedDatabaseData = []; + self::$cachedTableData = []; + } + /** * Storage */ From b6afef6efcf5fa07b0dcb3f2e2ad81a622ba780a Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 04:44:19 +0100 Subject: [PATCH 029/115] tests: trim verbose multi-paragraph docblocks to one-liners --- .../Services/Migrations/MigrationsBase.php | 122 ++---------------- 1 file changed, 13 insertions(+), 109 deletions(-) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index b38da99554..5d8ff48e53 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -761,15 +761,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Appwrite → Appwrite row re-migration honoring onDuplicate=skip and onDuplicate=upsert. - * - * With utopia-php/migration's DestinationAppwrite handling schema tolerance - * (pre-check the destination `_metadata` for each database / table / column - * / index, tolerate existing in Skip/Upsert), re-migration completes - * cleanly — no more schema-level errors to tolerate. The test asserts - * strict 'completed' status via performMigrationSync on every run. - */ + /** Rows under all three modes; schema tolerance lets every run hit 'completed'. */ public function testAppwriteMigrationRowsOnDuplicate(): void { $sourceHeaders = [ @@ -854,14 +846,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Re-migrating unchanged source (Skip / Upsert) completes cleanly without - * touching destination rows. Proves the schema-tolerance path: every - * database / table / attribute on destination already exists with a - * matching spec, so DestinationAppwrite's pre-check returns Tolerate for - * every resource and no-ops row writes go through the DB-native conflict - * primitives (INSERT IGNORE / ON DUPLICATE KEY UPDATE). - */ + /** Unchanged source under Skip/Upsert is a no-op — every resource Tolerated. */ public function testAppwriteMigrationReRunIsIdempotent(): void { $sourceHeaders = [ @@ -939,13 +924,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Upsert re-migration reconciles container metadata drift: when source's - * database or table was modified between runs (rename, enabled flag, - * permissions tightening), the Upsert pre-check returns UpdateInPlace - * and migration's updateDocument propagates source values to dest. - * Children (rows) are preserved. - */ + /** Upsert reconciles container drift via UpdateInPlace; children (rows) preserved. */ public function testAppwriteMigrationUpsertUpdatesContainerMetadata(): void { $sourceHeaders = [ @@ -1035,14 +1014,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Skip mode is strict "don't touch" — destination container metadata - * drift (permissions tightened post-migration, rename on dest) is - * preserved on every re-run, even when source has diverged. Guards - * against a common production workflow: dev→prod migrate, ops tightens - * prod permissions, later schema-only re-sync must not wipe out the - * tightening. - */ + /** Skip preserves dest container drift even when source has diverged. */ public function testAppwriteMigrationSkipPreservesContainerDrift(): void { $sourceHeaders = [ @@ -1117,14 +1089,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Upsert re-migration reconciles column-level drift: a column that - * exists only on destination (e.g. from a subsequent dest-side edit, or - * left over after a source-side rename) must be dropped so destination's - * schema matches what source declares. Source-declared columns are - * preserved. Rows land after the orphan drop so row upsert doesn't - * fail on orphan required columns. - */ + /** Upsert drops dest columns source no longer declares; cleanup runs before rows land. */ public function testAppwriteMigrationUpsertDropsOrphanColumn(): void { $sourceHeaders = [ @@ -1211,11 +1176,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Skip mode never touches destination, including orphan columns. - * Pairs with testAppwriteMigrationUpsertDropsOrphanColumn to prove the - * cleanup is gated correctly: only Upsert reconciles, Skip tolerates. - */ + /** Skip preserves orphan columns; cleanup is Upsert-only. */ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void { $sourceHeaders = [ @@ -1291,14 +1252,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Upsert reconciles attribute-level metadata edits using the Appwrite SDK's - * per-type updateXAttribute endpoint instead of drop+recreate. Source-side - * PATCH of fields the SDK can express (`required`, `default`, `size` for - * strings) — same `$createdAt` on both sides, source `$updatedAt` newer — - * routes through `updateAttributeInPlace` on DestinationAppwrite. Existing - * row data must survive (drop+recreate would have wiped the column). - */ + /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */ public function testAppwriteMigrationUpsertUpdatesAttributeInPlace(): void { $sourceHeaders = [ @@ -1396,14 +1350,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Skip mode is "don't touch" at the attribute level too: destination - * column drift (ops loosened a column post-migration) must survive a - * Skip re-run, even when source's `$updatedAt` is strictly newer. - * Pairs with testAppwriteMigrationSkipPreservesContainerDrift but - * exercises the leaf path (`canDrop = true`) instead of the container - * path. Regression guard against Skip ever consulting timestamps. - */ + /** Skip preserves dest attribute drift; leaf-level analog of the container drift test. */ public function testAppwriteMigrationSkipPreservesAttributeDrift(): void { $sourceHeaders = [ @@ -1489,17 +1436,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Upsert reconciles two-way relationship `onDelete` drift via the SDK's - * updateRelationshipAttribute (only `onDelete`/`newKey` are SDK-reachable). - * Two-way is required to exercise updateRelationshipInPlace — one-way + - * onDelete change falls through to DropAndRecreate (utopia's - * updateRelationship partner-cascade throws on one-way). - * - * Asserts both sides of the relationship reflect the new onDelete on dest: - * utopia's updateRelationship syncs the physical constraint on both sides, - * but the Appwrite-level partner meta doc has to be refreshed explicitly. - */ + /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */ public function testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace(): void { $sourceHeaders = [ @@ -1621,16 +1558,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Two-way DropAndRecreate path: source recreates the relationship between - * runs (createdAt diff). Without partner-side pair-key dedup, the second - * createField pass for the partner can re-fire DropAndRecreate after the - * first side already reconciled both physical columns, destroying rows - * already migrated to the partner table this run. - * - * Asserts: migration completes, both sides exist on dest, child rows - * referencing the parent are preserved end-to-end. - */ + /** Pair-key dedup prevents partner DropAndRecreate from wiping rows already migrated this run. */ public function testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide(): void { $sourceHeaders = [ @@ -1775,14 +1703,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * One-way + onDelete change is gated to false in updateRelationshipInPlace - * (utopia's updateRelationship partner-cascade throws on one-way), so the - * caller falls through to DropAndRecreate via deleteRelationship. Asserts - * dest's onDelete reflects source's new value end-to-end. Companion to - * testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace which - * exercises the two-way in-place path. - */ + /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */ public function testAppwriteMigrationUpsertOneWayRelationshipDropAndRecreate(): void { $sourceHeaders = [ @@ -1885,14 +1806,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Source drops + recreates a regular attribute between runs (createdAt - * differs, leaf, canDrop=true). Re-migration must DropAndRecreate the - * destination attribute and re-flow the row data — proving the - * createdAt-aware decision distinguishes "physically recreated" from - * "metadata edit". Pairs with testAppwriteMigrationUpsertUpdatesAttributeInPlace - * which exercises the same-createdAt + newer-updatedAt path. - */ + /** Source drops+recreates with DIFFERENT spec: DropAndRecreate, row pass refills. */ public function testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates(): void { $sourceHeaders = [ @@ -1990,17 +1904,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** - * Source drops + recreates a column with the EXACT same spec. createdAt - * advances on source, but the spec-match guard short-circuits the - * DropAndRecreate to Tolerate — dest's column meta doc stays untouched - * (verified via $createdAt invariance). Row pass under Upsert still - * propagates source's new row values via upsertDocuments. - * - * Companion to testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates - * which exercises the spec-DIFFERS path: same precondition, different - * outcome based on whether spec matches. - */ + /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */ public function testAppwriteMigrationUpsertSameSpecRecreateTolerates(): void { $sourceHeaders = [ From f5730e8eedc41895a050896caae4e5ebdce57bce Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 05:16:46 +0100 Subject: [PATCH 030/115] composer: bump utopia-php/migration to 36a1acf (spec-match order fix) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 15a7039b6d..b1790ec212 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "c8d17898458de4a297ba03fd1a126f7aa13227af" + "reference": "36a1acf75027a22b0ad1a7374b8f88c94ab30f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/c8d17898458de4a297ba03fd1a126f7aa13227af", - "reference": "c8d17898458de4a297ba03fd1a126f7aa13227af", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/36a1acf75027a22b0ad1a7374b8f88c94ab30f68", + "reference": "36a1acf75027a22b0ad1a7374b8f88c94ab30f68", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T03:12:58+00:00" + "time": "2026-04-28T04:16:28+00:00" }, { "name": "utopia-php/mongo", From 5a928f2c0f0e5b7de7605706a1479b18b39b43a0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 05:43:43 +0100 Subject: [PATCH 031/115] composer: bump utopia-php/migration to 6ec2c45 (drop createdAt) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index b1790ec212..032f778700 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "36a1acf75027a22b0ad1a7374b8f88c94ab30f68" + "reference": "6ec2c45a4077d9452ae32e4faa1b49375ad77cae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/36a1acf75027a22b0ad1a7374b8f88c94ab30f68", - "reference": "36a1acf75027a22b0ad1a7374b8f88c94ab30f68", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/6ec2c45a4077d9452ae32e4faa1b49375ad77cae", + "reference": "6ec2c45a4077d9452ae32e4faa1b49375ad77cae", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T04:16:28+00:00" + "time": "2026-04-28T04:43:11+00:00" }, { "name": "utopia-php/mongo", From 443f5cfb0ec9ce813006e66ec1e0fd566141a839 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 05:55:38 +0100 Subject: [PATCH 032/115] tests: switch attribute-recreate test to non-SDK change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After dropping createdAt from resolveSchemaAction, source-side recreate no longer routes through DropAndRecreate via the outer decision. The inner fallthrough still drops+recreates when the spec diff is a non-SDK change, so this test now toggles 'array' (a non-SDK field) on recreate to actually exercise the drop+recreate path it pins. Also clarifies the two-way recreate test's docblock — with createdAt gone and identical spec on recreate, it exercises spec-match + pair-key dedup (both tolerate paths) rather than parent-side drop. End-state assertions unchanged. --- .../e2e/Services/Migrations/MigrationsBase.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 5d8ff48e53..35dfad297d 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1558,7 +1558,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Pair-key dedup prevents partner DropAndRecreate from wiping rows already migrated this run. */ + /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */ public function testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide(): void { $sourceHeaders = [ @@ -1806,7 +1806,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Source drops+recreates with DIFFERENT spec: DropAndRecreate, row pass refills. */ + /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */ public function testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates(): void { $sourceHeaders = [ @@ -1858,10 +1858,15 @@ trait MigrationsBase $this->assertEquals(404, $r['headers']['status-code']); }, 10000, 500); + // Recreate with `array: true` — a non-SDK change (`array` is in + // ATTRIBUTE_NON_SDK_FIELDS). Forces updateAttributeInPlace to bail + // and the caller to fall through to drop+recreate, which is what + // this test pins. $recreate = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', $sourceHeaders, [ 'key' => 'name', 'size' => 100, 'required' => false, + 'array' => true, ]); $this->assertEquals(202, $recreate['headers']['status-code']); @@ -1871,9 +1876,9 @@ trait MigrationsBase $this->assertEquals('available', $r['body']['status']); }, 10000, 500); - // Source row's data was nulled by the source-side delete. Set fresh value. + // Source row's data was nulled by the source-side delete. Set a list value (column is array=true now). $relink = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $sourceHeaders, [ - 'data' => ['name' => 'after-recreate'], + 'data' => ['name' => ['after-recreate']], ]); $this->assertEquals(200, $relink['headers']['status-code']); @@ -1890,12 +1895,13 @@ trait MigrationsBase $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); $this->assertEquals(200, $col['headers']['status-code']); $this->assertEquals('available', $col['body']['status']); - $this->assertFalse($col['body']['required'], 'recreated column must reflect the new spec (required=false)'); + $this->assertTrue($col['body']['array'], 'recreated column must reflect the new spec (array=true)'); + $this->assertFalse($col['body']['required']); }, 10000, 500); $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfter['headers']['status-code']); - $this->assertEquals('after-recreate', $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value'); + $this->assertEquals(['after-recreate'], $rowAfter['body']['name'], 'row pass must repopulate the recreated column with source value'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); From 4ae4531355d89a5075cedac72459120555e11fca Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 07:06:25 +0100 Subject: [PATCH 033/115] composer: bump utopia-php/migration to aa1e7c7 (drop+recreate fixes) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 032f778700..87f442e9f1 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "6ec2c45a4077d9452ae32e4faa1b49375ad77cae" + "reference": "aa1e7c706326542a050354635f4290df43f06443" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/6ec2c45a4077d9452ae32e4faa1b49375ad77cae", - "reference": "6ec2c45a4077d9452ae32e4faa1b49375ad77cae", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/aa1e7c706326542a050354635f4290df43f06443", + "reference": "aa1e7c706326542a050354635f4290df43f06443", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T04:43:11+00:00" + "time": "2026-04-28T06:03:39+00:00" }, { "name": "utopia-php/mongo", From 40aa8cb1bdd3219a55c6b78722138e7ebfef4ea0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 07:11:23 +0100 Subject: [PATCH 034/115] composer: bump utopia-php/migration to 4c3965b (tighten dropAttributeForRecreate) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 87f442e9f1..675b7d1d14 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "aa1e7c706326542a050354635f4290df43f06443" + "reference": "4c3965bc89557109d80ee1443f0efa59c07c8465" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/aa1e7c706326542a050354635f4290df43f06443", - "reference": "aa1e7c706326542a050354635f4290df43f06443", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/4c3965bc89557109d80ee1443f0efa59c07c8465", + "reference": "4c3965bc89557109d80ee1443f0efa59c07c8465", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T06:03:39+00:00" + "time": "2026-04-28T06:11:04+00:00" }, { "name": "utopia-php/mongo", From e1209614c5252c168bb44823795f6dcbec80efb3 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 07:56:14 +0100 Subject: [PATCH 035/115] composer: bump utopia-php/migration to d37efed (track partner key for orphan cleanup) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 675b7d1d14..77ac114061 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "4c3965bc89557109d80ee1443f0efa59c07c8465" + "reference": "d37efed0d977a5f0c164861e58fc807763b1115f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/4c3965bc89557109d80ee1443f0efa59c07c8465", - "reference": "4c3965bc89557109d80ee1443f0efa59c07c8465", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/d37efed0d977a5f0c164861e58fc807763b1115f", + "reference": "d37efed0d977a5f0c164861e58fc807763b1115f", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T06:11:04+00:00" + "time": "2026-04-28T06:55:53+00:00" }, { "name": "utopia-php/mongo", From 8f32d0168617ced138fb840e6daa8843952fecb9 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 28 Apr 2026 08:10:39 +0100 Subject: [PATCH 036/115] composer: bump utopia-php/migration to 7d71505 (createIndex pre-check before count/validator) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 77ac114061..6ee740d807 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "d37efed0d977a5f0c164861e58fc807763b1115f" + "reference": "7d71505ec0a5731f322bbdc83cc9bee32053df2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/d37efed0d977a5f0c164861e58fc807763b1115f", - "reference": "d37efed0d977a5f0c164861e58fc807763b1115f", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/7d71505ec0a5731f322bbdc83cc9bee32053df2e", + "reference": "7d71505ec0a5731f322bbdc83cc9bee32053df2e", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T06:55:53+00:00" + "time": "2026-04-28T07:10:11+00:00" }, { "name": "utopia-php/mongo", From 7648978cfa78e15c62fcb79a1b30b39bbb95d98d Mon Sep 17 00:00:00 2001 From: adityaoberai Date: Wed, 29 Apr 2026 19:22:46 +0530 Subject: [PATCH 037/115] Add codex plugin to SDKs --- app/config/sdks.php | 20 ++++++++++++++++++++ src/Appwrite/Platform/Tasks/SDKs.php | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/app/config/sdks.php b/app/config/sdks.php index e89265b05e..cbfe76dce4 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -320,6 +320,26 @@ return [ 'repoBranch' => 'main', 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'), ], + [ + 'key' => 'codex-plugin', + 'name' => 'CodexPlugin', + 'version' => '0.1.0', + 'url' => 'https://github.com/appwrite/codex-plugin.git', + 'enabled' => true, + 'beta' => false, + 'dev' => false, + 'hidden' => false, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, + 'prism' => 'codex-plugin', + 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'), + 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git', + 'gitRepoName' => 'codex-plugin', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'repoBranch' => 'main', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'), + ], ], ], diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index b1580f0e68..fbf965bd00 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android; use Appwrite\SDK\Language\Apple; use Appwrite\SDK\Language\ClaudePlugin; use Appwrite\SDK\Language\CLI; +use Appwrite\SDK\Language\CodexPlugin; use Appwrite\SDK\Language\CursorPlugin; use Appwrite\SDK\Language\Dart; use Appwrite\SDK\Language\Deno; @@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND case 'claude-plugin': $config = new ClaudePlugin(); break; + case 'codex-plugin': + $config = new CodexPlugin(); + break; default: throw new \Exception('Language "' . $language['key'] . '" not supported'); } From d4e32af792883202c047f81cfc9acce70889b908 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 30 Apr 2026 11:45:33 +0100 Subject: [PATCH 038/115] Migrate appwrite to OnDuplicate::Overwrite ('overwrite') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer review on utopia-php/migration#171 renamed OnDuplicate::Upsert -> OnDuplicate::Overwrite (value 'upsert' -> 'overwrite') to align with Appwrite terms (skip / overwrite / fail). Applying the cross-repo ripple here: - app/controllers/api/migrations.php: 3 endpoint param descriptions updated ('upsert' -> 'overwrite' in the help text). The validator still uses OnDuplicate::values() so it auto-picks up the new value. - tests/e2e/Services/Migrations/MigrationsBase.php: all 'onDuplicate' => 'upsert' -> 'overwrite'; method names testAppwriteMigrationUpsert* -> testAppwriteMigrationOverwrite*; comments / assertion messages / local var names switched. - Left untouched: utopia's upsertDocuments operation, transaction TransactionState 'upsert' action, Operation validator — those refer to the database-level upsert primitive, not the OnDuplicate enum. composer.lock: utopia-php/migration 7d71505 -> b8ae7bc. --- app/controllers/api/migrations.php | 6 +- composer.lock | 8 +- .../Services/Migrations/MigrationsBase.php | 130 +++++++++--------- 3 files changed, 72 insertions(+), 72 deletions(-) diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index c9f810f353..73b93c4c5f 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -89,7 +89,7 @@ Http::post('/v1/migrations/appwrite') ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) ->param('apiKey', '', new Text(512), 'Source API Key') - ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -358,7 +358,7 @@ Http::post('/v1/migrations/csv/imports') ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -665,7 +665,7 @@ Http::post('/v1/migrations/json/imports') ->param('fileId', '', new UID(), 'File ID.') ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) - ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "upsert": replace existing row.', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') diff --git a/composer.lock b/composer.lock index 6ee740d807..191eba9608 100644 --- a/composer.lock +++ b/composer.lock @@ -4532,12 +4532,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7d71505ec0a5731f322bbdc83cc9bee32053df2e" + "reference": "b8ae7bc953d1897994eaa0de7e677687a19e5133" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7d71505ec0a5731f322bbdc83cc9bee32053df2e", - "reference": "7d71505ec0a5731f322bbdc83cc9bee32053df2e", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/b8ae7bc953d1897994eaa0de7e677687a19e5133", + "reference": "b8ae7bc953d1897994eaa0de7e677687a19e5133", "shasum": "" }, "require": { @@ -4579,7 +4579,7 @@ "issues": "https://github.com/utopia-php/migration/issues", "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" }, - "time": "2026-04-28T07:10:11+00:00" + "time": "2026-04-30T10:24:12+00:00" }, { "name": "utopia-php/mongo", diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 35dfad297d..0f54eff658 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -824,20 +824,20 @@ trait MigrationsBase $this->assertEquals(200, $rowAfterSkip['headers']['status-code']); $this->assertEquals('Mutated', $rowAfterSkip['body']['name'], 'onDuplicate=skip must not overwrite destination row'); - // Re-migration with onDuplicate=upsert — strict completion; destination + // Re-migration with onDuplicate=overwrite — strict completion; destination // row restored to source value. - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); - $rowAfterUpsert = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); - $this->assertEquals(200, $rowAfterUpsert['headers']['status-code']); - $this->assertEquals('Original', $rowAfterUpsert['body']['name'], 'onDuplicate=upsert must restore source value'); + $rowAfterOverwrite = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); + $this->assertEquals(200, $rowAfterOverwrite['headers']['status-code']); + $this->assertEquals('Original', $rowAfterOverwrite['body']['name'], 'onDuplicate=overwrite must restore source value'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); @@ -846,7 +846,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Unchanged source under Skip/Upsert is a no-op — every resource Tolerated. */ + /** Unchanged source under Skip/Overwrite is a no-op — every resource Tolerated. */ public function testAppwriteMigrationReRunIsIdempotent(): void { $sourceHeaders = [ @@ -900,16 +900,16 @@ trait MigrationsBase ]); $this->assertEquals('completed', $reRunSkip['status']); - // Re-run under Upsert: same unchanged source. Schema tolerance path + // Re-run under Overwrite: same unchanged source. Schema tolerance path // fires for each resource; rows go through DB-native upsert. - $reRunUpsert = $this->performMigrationSync([ + $reRunOverwrite = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $reRunUpsert['status']); + $this->assertEquals('completed', $reRunOverwrite['status']); foreach (['row-a', 'row-b'] as $rowId) { $check = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); @@ -924,8 +924,8 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Upsert reconciles container drift via UpdateInPlace; children (rows) preserved. */ - public function testAppwriteMigrationUpsertUpdatesContainerMetadata(): void + /** Overwrite reconciles container drift via UpdateInPlace; children (rows) preserved. */ + public function testAppwriteMigrationOverwriteUpdatesContainerMetadata(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -980,15 +980,15 @@ trait MigrationsBase 'enabled' => false, ]); - // Upsert re-migration: UpdateInPlace path fires for database + table. - $upsertResult = $this->performMigrationSync([ + // Overwrite re-migration: UpdateInPlace path fires for database + table. + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); // Assert dest database metadata reflects source's new values. $destDb = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId, $destHeaders); @@ -999,13 +999,13 @@ trait MigrationsBase $destTable = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, $destHeaders); $this->assertEquals(200, $destTable['headers']['status-code']); $this->assertEquals('Renamed Source Table', $destTable['body']['name']); - $this->assertFalse($destTable['body']['enabled'], 'Upsert must propagate source enabled=false'); - $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Upsert must propagate source rowSecurity=true'); + $this->assertFalse($destTable['body']['enabled'], 'Overwrite must propagate source enabled=false'); + $this->assertTrue($destTable['body']['documentSecurity'] ?? $destTable['body']['rowSecurity'], 'Overwrite must propagate source rowSecurity=true'); // Child row untouched — UpdateInPlace only rewrites container metadata. $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $row['headers']['status-code']); - $this->assertEquals('SeedRow', $row['body']['name'], 'Upsert must not touch child rows when updating container metadata'); + $this->assertEquals('SeedRow', $row['body']['name'], 'Overwrite must not touch child rows when updating container metadata'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); @@ -1089,8 +1089,8 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Upsert drops dest columns source no longer declares; cleanup runs before rows land. */ - public function testAppwriteMigrationUpsertDropsOrphanColumn(): void + /** Overwrite drops dest columns source no longer declares; cleanup runs before rows land. */ + public function testAppwriteMigrationOverwriteDropsOrphanColumn(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1151,23 +1151,23 @@ trait MigrationsBase 'data' => ['name' => 'seed'], ]); - // Upsert re-migration: orphan_col must be dropped from dest. - $upsertResult = $this->performMigrationSync([ + // Overwrite re-migration: orphan_col must be dropped from dest. + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); // Orphan column dropped. $orphanCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/orphan_col', $destHeaders); - $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Upsert must drop destination column source no longer declares'); + $this->assertEquals(404, $orphanCheck['headers']['status-code'], 'Overwrite must drop destination column source no longer declares'); // Source's column preserved. $nameCheck = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); - $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Upsert must preserve columns source declared'); + $this->assertEquals(200, $nameCheck['headers']['status-code'], 'Overwrite must preserve columns source declared'); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $destHeaders); $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, $sourceHeaders); @@ -1176,7 +1176,7 @@ trait MigrationsBase self::$cachedTableData = []; } - /** Skip preserves orphan columns; cleanup is Upsert-only. */ + /** Skip preserves orphan columns; cleanup is Overwrite-only. */ public function testAppwriteMigrationSkipKeepsOrphanColumn(): void { $sourceHeaders = [ @@ -1253,7 +1253,7 @@ trait MigrationsBase } /** SDK-reachable attribute change propagates via updateAttributeInPlace; row data preserved. */ - public function testAppwriteMigrationUpsertUpdatesAttributeInPlace(): void + public function testAppwriteMigrationOverwriteUpdatesAttributeInPlace(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1320,14 +1320,14 @@ trait MigrationsBase $this->assertEquals('unknown', $r['body']['default']); }, 5000, 500); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); @@ -1401,7 +1401,7 @@ trait MigrationsBase sleep(1); // Source advances strictly later (and to a different value). Under - // Upsert this would propagate to dest; under Skip it must not. + // Overwrite this would propagate to dest; under Skip it must not. $sourcePatch = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string/name', $sourceHeaders, [ 'required' => true, 'default' => null, @@ -1437,7 +1437,7 @@ trait MigrationsBase } /** Two-way onDelete change updates in place on both sides; partner meta refreshed by hand. */ - public function testAppwriteMigrationUpsertUpdatesRelationshipOnDeleteInPlace(): void + public function testAppwriteMigrationOverwriteUpdatesRelationshipOnDeleteInPlace(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1525,14 +1525,14 @@ trait MigrationsBase $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); }, 5000, 500); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); // Both sides on dest must reflect onDelete=restrict. Asserting the // partner side is the regression guard for the previously-missed @@ -1559,7 +1559,7 @@ trait MigrationsBase } /** Two-way recreate with same spec: spec-match guard tolerates parent; pair-key dedup tolerates partner. Both sides + child rows preserved. */ - public function testAppwriteMigrationUpsertTwoWayRecreateSkipsPartnerSide(): void + public function testAppwriteMigrationOverwriteTwoWayRecreateSkipsPartnerSide(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1670,14 +1670,14 @@ trait MigrationsBase ]); $this->assertEquals(200, $relink['headers']['status-code']); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $destHeaders) { $parent = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); @@ -1704,7 +1704,7 @@ trait MigrationsBase } /** One-way + onDelete change falls through to DropAndRecreate (in-place gated off for one-way). */ - public function testAppwriteMigrationUpsertOneWayRelationshipDropAndRecreate(): void + public function testAppwriteMigrationOverwriteOneWayRelationshipDropAndRecreate(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1781,14 +1781,14 @@ trait MigrationsBase $this->assertEquals(Database::RELATION_MUTATE_RESTRICT, $r['body']['onDelete']); }, 5000, 500); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $destHeaders) { $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/kids', $destHeaders); @@ -1807,7 +1807,7 @@ trait MigrationsBase } /** Recreate with non-SDK spec change (array toggle): updateAttributeInPlace bails → drop+recreate; row pass refills. */ - public function testAppwriteMigrationUpsertAttributeRecreateDropsAndRecreates(): void + public function testAppwriteMigrationOverwriteAttributeRecreateDropsAndRecreates(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1882,14 +1882,14 @@ trait MigrationsBase ]); $this->assertEquals(200, $relink['headers']['status-code']); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); $this->assertEventually(function () use ($databaseId, $tableId, $destHeaders) { $col = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/name', $destHeaders); @@ -1911,7 +1911,7 @@ trait MigrationsBase } /** Source drops+recreates with SAME spec: spec-match guard forces Tolerate; dest meta untouched. */ - public function testAppwriteMigrationUpsertSameSpecRecreateTolerates(): void + public function testAppwriteMigrationOverwriteSameSpecRecreateTolerates(): void { $sourceHeaders = [ 'content-type' => 'application/json', @@ -1984,14 +1984,14 @@ trait MigrationsBase ]); $this->assertEquals(200, $relink['headers']['status-code']); - $upsertResult = $this->performMigrationSync([ + $overwriteResult = $this->performMigrationSync([ 'resources' => $resources, 'endpoint' => $this->webEndpoint, 'projectId' => $this->getProject()['$id'], 'apiKey' => $this->getProject()['apiKey'], - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); - $this->assertEquals('completed', $upsertResult['status']); + $this->assertEquals('completed', $overwriteResult['status']); // Spec-match guard fired → dest column's $createdAt stayed at the // first-migration value. If DropAndRecreate had run, $createdAt @@ -2002,7 +2002,7 @@ trait MigrationsBase $this->assertEquals(100, $destAfter['body']['size']); $this->assertTrue($destAfter['body']['required']); - // Row pass under Upsert still propagated source's new row value. + // Row pass under Overwrite still propagated source's new row value. $rowAfter = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, $destHeaders); $this->assertEquals(200, $rowAfter['headers']['status-code']); $this->assertEquals('after-recreate', $rowAfter['body']['name']); @@ -2884,7 +2884,7 @@ trait MigrationsBase } /** - * onDuplicate=upsert on re-import: existing rows are replaced with imported values. + * onDuplicate=overwrite on re-import: existing rows are replaced with imported values. */ public function testCreateCSVImportOverwrite(): void { @@ -2905,7 +2905,7 @@ trait MigrationsBase $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); }, 10_000, 500); - // Mutate one row so we can prove upsert restores it to the CSV's original value + // Mutate one row so we can prove overwrite restores it to the CSV's original value $mutate = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -2916,12 +2916,12 @@ trait MigrationsBase $this->assertEquals(200, $mutate['headers']['status-code']); $this->assertEquals(22, $mutate['body']['age']); - // Second import with onDuplicate=upsert: mutated row restored to CSV value + // Second import with onDuplicate=overwrite: mutated row restored to CSV value $second = $this->performCsvMigration([ 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ @@ -2931,14 +2931,14 @@ trait MigrationsBase $this->assertEquals('completed', $migration['body']['status']); }, 10_000, 500); - // Mutated row is back to CSV's original age (proving upsert actually replaced the row) + // Mutated row is back to CSV's original age (proving overwrite actually replaced the row) $row = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows/' . $rowId, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); - $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=upsert must restore row to imported value'); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); // Row count still 100 (no duplicates created) $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ @@ -3141,7 +3141,7 @@ trait MigrationsBase } /** - * onDuplicate=upsert on JSON re-import: existing rows replaced with imported values. + * onDuplicate=overwrite on JSON re-import: existing rows replaced with imported values. */ public function testCreateJSONImportOverwrite(): void { @@ -3175,7 +3175,7 @@ trait MigrationsBase 'fileId' => $fileId, 'bucketId' => $bucketId, 'resourceId' => $databaseId . ':' . $tableId, - 'onDuplicate' => 'upsert', + 'onDuplicate' => 'overwrite', ]); $this->assertEventually(function () use ($second) { $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $second['body']['$id'], array_merge([ @@ -3191,7 +3191,7 @@ trait MigrationsBase ], $this->getHeaders())); $this->assertEquals(200, $row['headers']['status-code']); $this->assertEquals($originalName, $row['body']['name']); - $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=upsert must restore row to imported value'); + $this->assertEquals($originalAge, $row['body']['age'], 'onDuplicate=overwrite must restore row to imported value'); $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([ 'content-type' => 'application/json', From b79637eef51a7cb900491d3d6e15b2ef5e8a49fb Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 30 Apr 2026 17:01:57 +0100 Subject: [PATCH 039/115] Re-add onDuplicate param to modular migration endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.9.x reorganized app/controllers/api/migrations.php into the Platform/Modules/Migrations structure but dropped the onDuplicate param. After the merge, every e2e migration test that passed 'onDuplicate' => 'overwrite' would 400 since the param wasn't in the allowlist anymore. Restoring it on the three endpoints that take row-level conflict behavior: Appwrite/Create, CSV/Imports/Create, JSON/Imports/Create. Each: - Imports OnDuplicate + WhiteList. - Adds optional ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), …). - Threads $onDuplicate through the action signature. - Stores it on the migration document's 'options' attribute so Workers/Migrations.php can pick it up via OnDuplicate::tryFrom($options['onDuplicate'] ?? '') ?? OnDuplicate::Fail. Worker code already reads options['onDuplicate'] (unchanged) — no edits needed there. --- .../Modules/Migrations/Http/Migrations/Appwrite/Create.php | 6 ++++++ .../Migrations/Http/Migrations/CSV/Imports/Create.php | 5 +++++ .../Migrations/Http/Migrations/JSON/Imports/Create.php | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php index 006ab3ae90..fa700877a1 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/Appwrite/Create.php @@ -13,6 +13,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; @@ -57,6 +58,7 @@ class Create extends Action ->param('endpoint', '', new URL(), 'Source Appwrite endpoint') ->param('projectId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Source Project ID', false, ['dbForProject']) ->param('apiKey', '', new Text(512), 'Source API Key') + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('project') @@ -71,6 +73,7 @@ class Create extends Action string $endpoint, string $projectId, string $apiKey, + string $onDuplicate, Response $response, Database $dbForProject, Document $project, @@ -93,6 +96,9 @@ class Create extends Action 'statusCounters' => '{}', 'resourceData' => '{}', 'errors' => [], + 'options' => [ + 'onDuplicate' => $onDuplicate, + ], ])); $queueForEvents->setParam('migrationId', $migration->getId()); diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php index 5cc21241c3..4b47ed7d58 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/CSV/Imports/Create.php @@ -20,6 +20,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Migration\Sources\CSV; @@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\Boolean; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -67,6 +69,7 @@ class Create extends Action ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File ID.', false, ['dbForProject']) ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -85,6 +88,7 @@ class Create extends Action string $fileId, string $resourceId, bool $internalFile, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -183,6 +187,7 @@ class Create extends Action 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'onDuplicate' => $onDuplicate, ], ])); diff --git a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php index 55081b2645..c5d936711e 100644 --- a/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php +++ b/src/Appwrite/Platform/Modules/Migrations/Http/Migrations/JSON/Imports/Create.php @@ -20,6 +20,7 @@ use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\UID; +use Utopia\Migration\Destinations\OnDuplicate; use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite as AppwriteSource; use Utopia\Migration\Sources\JSON as JSONSource; @@ -29,6 +30,7 @@ use Utopia\Platform\Scope\HTTP; use Utopia\Storage\Device; use Utopia\System\System; use Utopia\Validator\Boolean; +use Utopia\Validator\WhiteList; class Create extends Action { @@ -66,6 +68,7 @@ class Create extends Action ->param('fileId', '', new UID(), 'File ID.') ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->param('onDuplicate', OnDuplicate::Fail->value, new WhiteList(OnDuplicate::values()), 'Behavior when a row with an existing $id is encountered. "fail" (default): abort on first conflict. "skip": silently ignore. "overwrite": replace existing row.', true) ->inject('response') ->inject('dbForProject') ->inject('dbForPlatform') @@ -84,6 +87,7 @@ class Create extends Action string $fileId, string $resourceId, bool $internalFile, + string $onDuplicate, Response $response, Database $dbForProject, Database $dbForPlatform, @@ -183,6 +187,7 @@ class Create extends Action 'options' => [ 'path' => $newPath, 'size' => $fileSize, + 'onDuplicate' => $onDuplicate, ], ])); From 4703e4ba1465d78dbc7228f9ab66ef64111fecc4 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 05:05:00 +0100 Subject: [PATCH 040/115] Gate Sentry logging in migrations worker by exception type The outer catch in the migrations worker now only calls logError when the caught Throwable is not a MigrationException. User-facing setup errors (invalid source type, missing project, etc.) are thrown as MigrationException with appropriate codes and stay in the migration report only. Removed the foreach loop that re-published collected errors to Sentry; with the library-side fix in utopia-php/migration, items in $source->getErrors() / $destination->getErrors() are by construction user errors that don't need Sentry routing. Hoisted setAttribute('errors', sanitizeErrors(...)) into finally so the migration document always reflects the consolidated error list, including on bug paths. --- src/Appwrite/Platform/Workers/Migrations.php | 47 +++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 69f72b8e27..63956bc90d 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -196,13 +196,13 @@ class Migrations extends Action $projectDB = null; $useAppwriteApiSource = false; if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { - throw new \Exception('Source projectId is required for Appwrite migrations'); + throw new MigrationException('', '', message: 'Source projectId is required for Appwrite migrations', code: MigrationException::CODE_VALIDATION); } if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); if ($this->sourceProject->isEmpty()) { - throw new \Exception('Source project not found for provided projectId'); + throw new MigrationException('', '', message: 'Source project not found for provided projectId', code: MigrationException::CODE_NOT_FOUND); } $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); @@ -265,7 +265,7 @@ class Migrations extends Action $this->deviceForMigrations, $this->dbForProject, ), - default => throw new \Exception('Invalid source type'), + default => throw new MigrationException('', '', message: 'Invalid source type', code: MigrationException::CODE_VALIDATION), }; $resources = $migration->getAttribute('resources', []); @@ -310,7 +310,7 @@ class Migrations extends Action $options['filename'], $options['columns'] ?? [], ), - default => throw new \Exception('Invalid destination type'), + default => throw new MigrationException('', '', message: 'Invalid destination type', code: MigrationException::CODE_VALIDATION), }; } @@ -521,7 +521,6 @@ class Migrations extends Action if (!empty($sourceErrors) || ! empty($destinationErrors)) { $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - $migration->setAttribute('errors', $this->sanitizeErrors($sourceErrors, $destinationErrors)); return; } @@ -536,35 +535,29 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - ]); - + // User-facing failures (validation, not found, conflict) are routed through + // MigrationException and stay in the migration report only. Anything else is + // a bug or infra failure and goes to Sentry with the full trace. + if (!$th instanceof MigrationException) { + call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ + 'migrationId' => $migration->getId(), + 'source' => $migration->getAttribute('source') ?? '', + 'destination' => $migration->getAttribute('destination') ?? '', + ]); + } } finally { try { + // Persist the consolidated error list regardless of which code path fired. + $migration->setAttribute('errors', $this->sanitizeErrors( + $source?->getErrors() ?? [], + $destination?->getErrors() ?? [], + )); + $this->updateMigrationDocument($migration, $project, $queueForRealtime); if ($migration->getAttribute('status', '') === 'failed') { Console::error('Migration(' . $migration->getSequence() . ':' . $migration->getId() . ') failed, Project(' . $this->project->getSequence() . ':' . $this->project->getId() . ')'); - $sourceErrors = $source?->getErrors() ?? []; - $destinationErrors = $destination?->getErrors() ?? []; - - foreach ([...$sourceErrors, ...$destinationErrors] as $error) { - /** @var MigrationException $error */ - if ($error->getCode() === 0 || $error->getCode() >= 500) { - ($this->logError)($error, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ - 'migrationId' => $migration->getId(), - 'source' => $migration->getAttribute('source') ?? '', - 'destination' => $migration->getAttribute('destination') ?? '', - 'resourceName' => $error->getResourceName(), - 'resourceGroup' => $error->getResourceGroup(), - ]); - } - } - $source?->error(); $destination?->error(); } From c2e4bc2ae3901d040dd2d942ed74ac082e0f10f2 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 07:27:27 +0100 Subject: [PATCH 041/115] Simplify sanitizeErrors now that jsonSerialize doesn't emit trace With Migration\Exception::jsonSerialize() no longer including the stack trace, sanitizeErrors no longer needs to decode/strip/re-encode each entry. Reduce it to a single json_encode pass. --- src/Appwrite/Platform/Workers/Migrations.php | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 63956bc90d..03578b4ddc 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -857,7 +857,7 @@ class Migrations extends Action } /** - * Sanitize migration errors, removing sensitive information like stack traces + * Encode migration errors as JSON strings for storage on the migration document. * * @param array $sourceErrors * @param array $destinationErrors @@ -867,20 +867,10 @@ class Migrations extends Action array $sourceErrors, array $destinationErrors, ): array { - $errors = []; - foreach ([...$sourceErrors, ...$destinationErrors] as $error) { - $encoded = \json_decode(\json_encode($error), true); - if (\is_array($encoded)) { - if (isset($encoded['trace'])) { - unset($encoded['trace']); - } - $errors[] = \json_encode($encoded); - } else { - $errors[] = \json_encode($error); - } - } - - return $errors; + return \array_map( + fn ($error) => \json_encode($error), + [...$sourceErrors, ...$destinationErrors], + ); } private function processMigrationResourceStats(array $resources, Context $usage, Document $projectDocument, UsagePublisher $publisherForUsage, string $source, Authorization $authorization, ?string $resourceId) From c849e652b3d454e78b830a8b69eded42fb467ed3 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 08:36:38 +0100 Subject: [PATCH 042/115] Pin utopia-php/migration to fix-migration-sentry-leak branch for testing Wires the worker against the matching utopia-php/migration branch so end-to-end testing of the Sentry-routing fix can run against both sides of the change set. Run `composer update utopia-php/migration --with-all-dependencies` after pulling to refresh composer.lock. Revert before merging. --- composer.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 683da6f21b..163e9bf839 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "1.9.*", + "utopia-php/migration": "dev-fix-migration-sentry-leak as 1.9.999", "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", @@ -118,5 +118,11 @@ "php-http/discovery": true, "tbachert/spi": true } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/utopia-php/migration" + } + ] } From 581fdb26dd0d399423fd514f28bf4ea123fb9233 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 08:57:56 +0100 Subject: [PATCH 043/115] Update composer.lock for utopia-php/migration branch pin --- composer.lock | 76 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/composer.lock b/composer.lock index 3edbc39614..7f38aea1cf 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": "4bee36b21a57e754d2b3417e72dc9599", + "content-hash": "0e8cd1a2446dfb54015d25fa130d081a", "packages": [ { "name": "adhocore/jwt", @@ -2708,16 +2708,16 @@ }, { "name": "symfony/http-client", - "version": "v7.4.8", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342" + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/01933e626c3de76bea1e22641e205e78f6a34342", - "reference": "01933e626c3de76bea1e22641e205e78f6a34342", + "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", + "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6", "shasum": "" }, "require": { @@ -2785,7 +2785,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.4.8" + "source": "https://github.com/symfony/http-client/tree/v7.4.9" }, "funding": [ { @@ -2805,7 +2805,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T12:55:43+00:00" + "time": "2026-04-29T13:25:15+00:00" }, { "name": "symfony/http-client-contracts", @@ -3850,22 +3850,23 @@ }, { "name": "utopia-php/database", - "version": "5.4.1", + "version": "5.6.0", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93" + "reference": "609ebcd64be1ec6fab00c5f46fce54acb0031b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/688d9422b5ff42ac2ecc29397d94891cfd772e93", - "reference": "688d9422b5ff42ac2ecc29397d94891cfd772e93", + "url": "https://api.github.com/repos/utopia-php/database/zipball/609ebcd64be1ec6fab00c5f46fce54acb0031b3c", + "reference": "609ebcd64be1ec6fab00c5f46fce54acb0031b3c", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-mongodb": "*", "ext-pdo": "*", + "ext-redis": "*", "php": ">=8.4", "utopia-php/cache": "1.*", "utopia-php/console": "0.1.*", @@ -3903,9 +3904,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/5.4.1" + "source": "https://github.com/utopia-php/database/tree/5.6.0" }, - "time": "2026-04-29T07:32:59+00:00" + "time": "2026-05-01T01:28:07+00:00" }, { "name": "utopia-php/detector", @@ -4530,16 +4531,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.5", + "version": "dev-fix-migration-sentry-leak", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599" + "reference": "276e7c25077a4dee670a806f715869f11367d932" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/952a4dfe232702f80e45c35129466a8d8cb4c599", - "reference": "952a4dfe232702f80e45c35129466a8d8cb4c599", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/276e7c25077a4dee670a806f715869f11367d932", + "reference": "276e7c25077a4dee670a806f715869f11367d932", "shasum": "" }, "require": { @@ -4565,7 +4566,25 @@ "Utopia\\Migration\\": "src/Migration" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Utopia\\Tests\\": "tests/Migration" + } + }, + "scripts": { + "test": [ + "./vendor/bin/phpunit" + ], + "lint": [ + "./vendor/bin/pint --test" + ], + "format": [ + "./vendor/bin/pint" + ], + "check": [ + "./vendor/bin/phpstan analyse --level 3 src tests --memory-limit 2G" + ] + }, "license": [ "MIT" ], @@ -4578,10 +4597,10 @@ "utopia" ], "support": { - "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.5" + "source": "https://github.com/utopia-php/migration/tree/fix-migration-sentry-leak", + "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-04-29T11:19:13+00:00" + "time": "2026-05-04T07:20:31+00:00" }, { "name": "utopia-php/mongo", @@ -8442,9 +8461,18 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "utopia-php/migration", + "version": "dev-fix-migration-sentry-leak", + "alias": "1.9.999", + "alias_normalized": "1.9.999.0" + } + ], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": { + "utopia-php/migration": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -8465,5 +8493,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } From 07d60bb36d25e9a8e307d29c56c10f8071630ad0 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 10:23:40 +0100 Subject: [PATCH 044/115] Capture bubbled exception in migration error report The catch block recorded status='failed' but didn't surface the bubbling exception's message on the migration document. Setup-time failures (e.g. "Source project not found for provided projectId") left the user looking at status='failed' with errors=[]. Capture the throwable in the catch and include it in the consolidated errors list when finally serializes to the migration document. --- src/Appwrite/Platform/Workers/Migrations.php | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 03578b4ddc..fa3900881d 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -428,6 +428,7 @@ class Migrations extends Action $transfer = $source = $destination = null; $aggregatedResources = []; + $caughtError = null; $host = System::getEnv('_APP_MIGRATION_HOST'); if (empty($host)) { @@ -535,6 +536,11 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); + // Remember the bubbled exception so the finally block can include it in the + // migration's errors attribute — otherwise setup-time failures (e.g. invalid + // credentials) would leave the user looking at status='failed' with no message. + $caughtError = $th; + // User-facing failures (validation, not found, conflict) are routed through // MigrationException and stay in the migration report only. Anything else is // a bug or infra failure and goes to Sentry with the full trace. @@ -547,10 +553,26 @@ class Migrations extends Action } } finally { try { + $sourceErrors = $source?->getErrors() ?? []; + $destinationErrors = $destination?->getErrors() ?? []; + + if ($caughtError !== null) { + $bubbled = $caughtError instanceof MigrationException + ? $caughtError + : new MigrationException( + resourceName: '', + resourceGroup: '', + message: $caughtError->getMessage(), + code: $caughtError->getCode(), + previous: $caughtError, + ); + $destinationErrors[] = $bubbled; + } + // Persist the consolidated error list regardless of which code path fired. $migration->setAttribute('errors', $this->sanitizeErrors( - $source?->getErrors() ?? [], - $destination?->getErrors() ?? [], + $sourceErrors, + $destinationErrors, )); $this->updateMigrationDocument($migration, $project, $queueForRealtime); From 3bb68ef4559ccf0880b9221d3b2f5d61d648ff2c Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 11:03:51 +0100 Subject: [PATCH 045/115] Trim verbose comments in migrations worker outer catch --- src/Appwrite/Platform/Workers/Migrations.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index fa3900881d..8025c20677 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -536,14 +536,9 @@ class Migrations extends Action $migration->setAttribute('status', 'failed'); $migration->setAttribute('stage', 'finished'); - // Remember the bubbled exception so the finally block can include it in the - // migration's errors attribute — otherwise setup-time failures (e.g. invalid - // credentials) would leave the user looking at status='failed' with no message. $caughtError = $th; - // User-facing failures (validation, not found, conflict) are routed through - // MigrationException and stay in the migration report only. Anything else is - // a bug or infra failure and goes to Sentry with the full trace. + // MigrationException is reserved for user-facing failures and stays in the migration report only. if (!$th instanceof MigrationException) { call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ 'migrationId' => $migration->getId(), @@ -569,7 +564,6 @@ class Migrations extends Action $destinationErrors[] = $bubbled; } - // Persist the consolidated error list regardless of which code path fired. $migration->setAttribute('errors', $this->sanitizeErrors( $sourceErrors, $destinationErrors, From 561b482e5439d4cce20b1c8735794453d7676e44 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Mon, 4 May 2026 11:20:00 +0100 Subject: [PATCH 046/115] Restore original sanitizeErrors decode/strip/encode flow --- src/Appwrite/Platform/Workers/Migrations.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 8025c20677..f5cc57a6c2 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -873,7 +873,7 @@ class Migrations extends Action } /** - * Encode migration errors as JSON strings for storage on the migration document. + * Sanitize migration errors, removing sensitive information like stack traces * * @param array $sourceErrors * @param array $destinationErrors @@ -883,10 +883,20 @@ class Migrations extends Action array $sourceErrors, array $destinationErrors, ): array { - return \array_map( - fn ($error) => \json_encode($error), - [...$sourceErrors, ...$destinationErrors], - ); + $errors = []; + foreach ([...$sourceErrors, ...$destinationErrors] as $error) { + $encoded = \json_decode(\json_encode($error), true); + if (\is_array($encoded)) { + if (isset($encoded['trace'])) { + unset($encoded['trace']); + } + $errors[] = \json_encode($encoded); + } else { + $errors[] = \json_encode($error); + } + } + + return $errors; } private function processMigrationResourceStats(array $resources, Context $usage, Document $projectDocument, UsagePublisher $publisherForUsage, string $source, Authorization $authorization, ?string $resourceId) From 9ab546d743ed4932974111884f3fc0817b23e1b4 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 5 May 2026 09:11:18 +0100 Subject: [PATCH 047/115] composer: pin utopia-php/migration to 1.9.7 (released) --- composer.json | 2 +- composer.lock | 27 +++++++++------------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index a040ce2de8..683da6f21b 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "dev-feat/skip-duplicates as 1.9.99", + "utopia-php/migration": "1.9.*", "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index ee77812b4b..6323a9a20a 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": "988e787481b0b962a21266142ec03467", + "content-hash": "4bee36b21a57e754d2b3417e72dc9599", "packages": [ { "name": "adhocore/jwt", @@ -4530,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "dev-feat/skip-duplicates", + "version": "1.9.7", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "d7c3d0536dbb9e37a4894cd0b3b0134a483e04e8" + "reference": "81b608a6871f56b70496803d12010823300aab6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/d7c3d0536dbb9e37a4894cd0b3b0134a483e04e8", - "reference": "d7c3d0536dbb9e37a4894cd0b3b0134a483e04e8", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/81b608a6871f56b70496803d12010823300aab6e", + "reference": "81b608a6871f56b70496803d12010823300aab6e", "shasum": "" }, "require": { @@ -4579,9 +4579,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/feat/skip-duplicates" + "source": "https://github.com/utopia-php/migration/tree/1.9.7" }, - "time": "2026-04-30T11:08:08+00:00" + "time": "2026-05-05T07:18:48+00:00" }, { "name": "utopia-php/mongo", @@ -8442,18 +8442,9 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [ - { - "package": "utopia-php/migration", - "version": "dev-feat/skip-duplicates", - "alias": "1.9.99", - "alias_normalized": "1.9.99.0" - } - ], + "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/migration": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { From f36105a7afa53f6513f320a591e4f024896e59a6 Mon Sep 17 00:00:00 2001 From: abhay-dev2901 Date: Tue, 5 May 2026 17:23:50 +0530 Subject: [PATCH 048/115] fix: pass platform env vars to function schedulers --- app/views/install/compose.phtml | 14 ++++++++++++++ docker-compose.yml | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 1bf36b7f6d..6ce1fb5cea 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -881,6 +881,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_ENV - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -909,6 +916,13 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); - _APP_ENV - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER diff --git a/docker-compose.yml b/docker-compose.yml index da5efac438..2ae1bf486a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1114,6 +1114,13 @@ services: - _APP_WORKER_PER_CORE - _APP_POOL_ADAPTER - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1145,6 +1152,13 @@ services: - _APP_WORKER_PER_CORE - _APP_POOL_ADAPTER - _APP_OPENSSL_KEY_V1 + - _APP_OPTIONS_FORCE_HTTPS + - _APP_DOMAIN + - _APP_CONSOLE_DOMAIN + - _APP_DOMAIN_FUNCTIONS + - _APP_DOMAIN_SITES + - _APP_MIGRATION_HOST + - _APP_CONSOLE_SCHEMA - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_REDIS_USER @@ -1478,4 +1492,4 @@ volumes: appwrite-sites: appwrite-builds: appwrite-config: - appwrite-models: \ No newline at end of file + appwrite-models: From 803f646239d8b39b0e411d75bbbc38cc60b11c0f Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Tue, 5 May 2026 17:19:40 +0100 Subject: [PATCH 049/115] Update composer.lock for utopia-php/migration da8205e --- composer.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/composer.lock b/composer.lock index 7f38aea1cf..43dd28b150 100644 --- a/composer.lock +++ b/composer.lock @@ -4535,12 +4535,12 @@ "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "276e7c25077a4dee670a806f715869f11367d932" + "reference": "da8205e8f3e927b2b860dddc2efca0e408171116" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/276e7c25077a4dee670a806f715869f11367d932", - "reference": "276e7c25077a4dee670a806f715869f11367d932", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/da8205e8f3e927b2b860dddc2efca0e408171116", + "reference": "da8205e8f3e927b2b860dddc2efca0e408171116", "shasum": "" }, "require": { @@ -4600,7 +4600,7 @@ "source": "https://github.com/utopia-php/migration/tree/fix-migration-sentry-leak", "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-05-04T07:20:31+00:00" + "time": "2026-05-05T15:50:58+00:00" }, { "name": "utopia-php/mongo", @@ -5039,16 +5039,16 @@ }, { "name": "utopia-php/storage", - "version": "2.0.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/utopia-php/storage.git", - "reference": "8a2e3a86fd01aaed675884146665308c2122264e" + "reference": "64e132a3768e22243eda36fe4262da22fd204f3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/storage/zipball/8a2e3a86fd01aaed675884146665308c2122264e", - "reference": "8a2e3a86fd01aaed675884146665308c2122264e", + "url": "https://api.github.com/repos/utopia-php/storage/zipball/64e132a3768e22243eda36fe4262da22fd204f3c", + "reference": "64e132a3768e22243eda36fe4262da22fd204f3c", "shasum": "" }, "require": { @@ -5085,22 +5085,22 @@ ], "support": { "issues": "https://github.com/utopia-php/storage/issues", - "source": "https://github.com/utopia-php/storage/tree/2.0.1" + "source": "https://github.com/utopia-php/storage/tree/2.0.2" }, - "time": "2026-04-29T09:05:48+00:00" + "time": "2026-05-01T15:06:16+00:00" }, { "name": "utopia-php/system", - "version": "0.10.1", + "version": "0.10.2", "source": { "type": "git", "url": "https://github.com/utopia-php/system.git", - "reference": "7c1669533bb9c285de19191270c8c1439161a78a" + "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/system/zipball/7c1669533bb9c285de19191270c8c1439161a78a", - "reference": "7c1669533bb9c285de19191270c8c1439161a78a", + "url": "https://api.github.com/repos/utopia-php/system/zipball/04229a822b147c1abaf1a92fb42c2d7aad4625df", + "reference": "04229a822b147c1abaf1a92fb42c2d7aad4625df", "shasum": "" }, "require": { @@ -5141,9 +5141,9 @@ ], "support": { "issues": "https://github.com/utopia-php/system/issues", - "source": "https://github.com/utopia-php/system/tree/0.10.1" + "source": "https://github.com/utopia-php/system/tree/0.10.2" }, - "time": "2026-03-15T21:07:41+00:00" + "time": "2026-05-05T14:33:41+00:00" }, { "name": "utopia-php/telemetry", From e63f9fd6a5784f0ebe9a063372453766dccd7161 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 6 May 2026 08:40:17 +0100 Subject: [PATCH 050/115] tests: send non-empty parent row data in TwoWayRecreate test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server-ce 1.9.x's tablesdb POST /rows tightened input validation: the modular Documents/Create.php rejects `data => []` with a 400 "missing data" because TablesDB's Rows/Create.php inherits the strict default of getSupportForEmptyDocument() = false (only DocumentsDB overrides it to true). The test was relying on the older permissive behavior to seed an empty parent row before the relationship cascade links it. Add a non-relationship `label` string column on the parents table and populate it with `data => ['label' => 'p1']` so the POST passes the empty-data guard. The test's actual assertion target — partner-side pair-key dedup on DropAndRecreate — is unchanged. Cascade fixes: testAppwriteMigrationOverwriteAttributeRecreate and testAppwriteMigrationOverwriteSameSpecRecreate were failing in the retry pass because TwoWayRecreate's bail at L1616 left source/dest state uncleaned. Once TwoWayRecreate completes, those tests see a clean project again. Caught in CI run 25419479164 / job 74562934987 on the MongoDB (dedicated) Migrations matrix. --- .../e2e/Services/Migrations/MigrationsBase.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index fa360bfce8..387f4fe0e1 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -1593,6 +1593,22 @@ trait MigrationsBase $this->assertEquals(201, $createTable['headers']['status-code']); } + // Add a non-relationship column on parents so we can POST a row with + // non-empty data. tablesdb POST /rows rejects empty data arrays in + // 1.9.x (Create.php:161 — getSupportForEmptyDocument() defaults false). + $createLabel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/string', $sourceHeaders, [ + 'key' => 'label', + 'size' => 32, + 'required' => false, + ]); + $this->assertEquals(202, $createLabel['headers']['status-code']); + + $this->assertEventually(function () use ($databaseId, $sourceHeaders) { + $r = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/parents/columns/label', $sourceHeaders); + $this->assertEquals(200, $r['headers']['status-code']); + $this->assertEquals('available', $r['body']['status']); + }, 10000, 500); + $createRel = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/columns/relationship', $sourceHeaders, [ 'relatedTableId' => 'children', 'type' => Database::RELATION_ONE_TO_MANY, @@ -1611,7 +1627,7 @@ trait MigrationsBase $parentRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/parents/rows', $sourceHeaders, [ 'rowId' => 'parent-1', - 'data' => [], + 'data' => ['label' => 'p1'], ]); $this->assertEquals(201, $parentRow['headers']['status-code']); $childRow = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/children/rows', $sourceHeaders, [ From b094993f77b9f7036ff863cb5c8ff0b741dc9506 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Wed, 6 May 2026 12:34:50 +0100 Subject: [PATCH 051/115] spike: convert worker throws to AppwriteException + isPublishable gate --- app/config/errors.php | 20 ++++++++++++++++++ src/Appwrite/Extend/Exception.php | 4 ++++ src/Appwrite/Platform/Workers/Migrations.php | 22 ++++++++++++++------ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/config/errors.php b/app/config/errors.php index fa112bcb6f..08cc497b3f 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1236,6 +1236,26 @@ return [ 'description' => 'The specified database type is not supported for CSV import or export operations.', 'code' => 400, ], + Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED, + 'description' => 'A source projectId is required for Appwrite migrations. Provide it in the migration credentials.', + 'code' => 400, + ], + Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND => [ + 'name' => Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND, + 'description' => 'The source project for the provided projectId was not found. Verify the projectId and the API key has access to it.', + 'code' => 404, + ], + Exception::MIGRATION_SOURCE_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_SOURCE_TYPE_INVALID, + 'description' => 'The migration source type is invalid. Use one of the supported source types.', + 'code' => 400, + ], + Exception::MIGRATION_DESTINATION_TYPE_INVALID => [ + 'name' => Exception::MIGRATION_DESTINATION_TYPE_INVALID, + 'description' => 'The migration destination type is invalid. Use one of the supported destination types.', + 'code' => 400, + ], /** Realtime */ Exception::REALTIME_MESSAGE_FORMAT_INVALID => [ diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index 6fc3e88635..54ee4069d1 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -346,6 +346,10 @@ class Exception extends \Exception public const string MIGRATION_IN_PROGRESS = 'migration_in_progress'; public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error'; public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported'; + public const string MIGRATION_SOURCE_PROJECT_ID_REQUIRED = 'migration_source_project_id_required'; + public const string MIGRATION_SOURCE_PROJECT_NOT_FOUND = 'migration_source_project_not_found'; + public const string MIGRATION_SOURCE_TYPE_INVALID = 'migration_source_type_invalid'; + public const string MIGRATION_DESTINATION_TYPE_INVALID = 'migration_destination_type_invalid'; /** Realtime */ public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid'; diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index f5cc57a6c2..f37571d2b0 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -196,13 +196,13 @@ class Migrations extends Action $projectDB = null; $useAppwriteApiSource = false; if ($source === SourceAppwrite::getName() && empty($credentials['projectId'])) { - throw new MigrationException('', '', message: 'Source projectId is required for Appwrite migrations', code: MigrationException::CODE_VALIDATION); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_ID_REQUIRED); } if (! empty($credentials['projectId'])) { $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); if ($this->sourceProject->isEmpty()) { - throw new MigrationException('', '', message: 'Source project not found for provided projectId', code: MigrationException::CODE_NOT_FOUND); + throw new Exception(Exception::MIGRATION_SOURCE_PROJECT_NOT_FOUND); } $sourceRegion = $this->sourceProject->getAttribute('region', 'default'); @@ -265,7 +265,7 @@ class Migrations extends Action $this->deviceForMigrations, $this->dbForProject, ), - default => throw new MigrationException('', '', message: 'Invalid source type', code: MigrationException::CODE_VALIDATION), + default => throw new Exception(Exception::MIGRATION_SOURCE_TYPE_INVALID), }; $resources = $migration->getAttribute('resources', []); @@ -310,7 +310,7 @@ class Migrations extends Action $options['filename'], $options['columns'] ?? [], ), - default => throw new MigrationException('', '', message: 'Invalid destination type', code: MigrationException::CODE_VALIDATION), + default => throw new Exception(Exception::MIGRATION_DESTINATION_TYPE_INVALID), }; } @@ -538,8 +538,18 @@ class Migrations extends Action $caughtError = $th; - // MigrationException is reserved for user-facing failures and stays in the migration report only. - if (!$th instanceof MigrationException) { + // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its + // registry-driven isPublishable() flag; library-thrown Migration\Exception is + // always user-facing; anything else falls back to the code heuristic. + if ($th instanceof Exception) { + $publish = $th->isPublishable(); + } elseif ($th instanceof MigrationException) { + $publish = false; + } else { + $publish = $th->getCode() === 0 || $th->getCode() >= 500; + } + + if ($publish) { call_user_func($this->logError, $th, 'appwrite-worker', 'appwrite-queue-' . self::getName(), [ 'migrationId' => $migration->getId(), 'source' => $migration->getAttribute('source') ?? '', From 85d7c27a38c62bfd9e20efd9b3d06d65ddd16053 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 03:50:08 +0100 Subject: [PATCH 052/115] spike: treat null exception code as publishable in fallback gate --- src/Appwrite/Platform/Workers/Migrations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 2af3752085..5588a77799 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -554,7 +554,7 @@ class Migrations extends Action } elseif ($th instanceof MigrationException) { $publish = false; } else { - $publish = $th->getCode() === 0 || $th->getCode() >= 500; + $publish = $th->getCode() === null || $th->getCode() === 0 || $th->getCode() >= 500; } if ($publish) { From 8e9fd3256615b0a38e6f808b058baa8c4446a1b6 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 07:41:47 +0100 Subject: [PATCH 053/115] spike: simplify gate fallback to always publish on unknown exception types --- src/Appwrite/Platform/Workers/Migrations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 5588a77799..e4274b2a8c 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -554,7 +554,7 @@ class Migrations extends Action } elseif ($th instanceof MigrationException) { $publish = false; } else { - $publish = $th->getCode() === null || $th->getCode() === 0 || $th->getCode() >= 500; + $publish = true; } if ($publish) { From e7d338466083cc1e72a7d6b4b3f71b20dda15f19 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 09:22:21 +0100 Subject: [PATCH 054/115] Pin utopia-php/migration to 1.10.* (release with Sentry-leak fix) --- composer.json | 2 +- composer.lock | 77 +++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/composer.json b/composer.json index 35961a37cf..e396f6ad10 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "dev-fix-migration-sentry-leak as 1.9.999", + "utopia-php/migration": "1.10.*", "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index ad0ad5b137..a1f8309c17 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": "b6fc61ff6050d420307ba99ed2e59d46", + "content-hash": "d0e14ee465162e3b5e56d6cef9ddbb8d", "packages": [ { "name": "adhocore/jwt", @@ -2641,16 +2641,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -2663,7 +2663,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2688,7 +2688,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -2699,12 +2699,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/http-client", @@ -2809,16 +2813,16 @@ }, { "name": "symfony/http-client-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client-contracts.git", - "reference": "75d7043853a42837e68111812f4d964b01e5101c" + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", - "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", "shasum": "" }, "require": { @@ -2831,7 +2835,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -2867,7 +2871,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" }, "funding": [ { @@ -2878,12 +2882,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-29T11:18:49+00:00" + "time": "2026-03-06T13:17:50+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -3212,16 +3220,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -3239,7 +3247,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3275,7 +3283,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -3295,7 +3303,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "tbachert/spi", @@ -4531,16 +4539,16 @@ }, { "name": "utopia-php/migration", - "version": "dev-fix-migration-sentry-leak", + "version": "1.10.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "da8205e8f3e927b2b860dddc2efca0e408171116" + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/da8205e8f3e927b2b860dddc2efca0e408171116", - "reference": "da8205e8f3e927b2b860dddc2efca0e408171116", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/759d6d61b327313cbeeeb4ea0c3e2459164b4827", + "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827", "shasum": "" }, "require": { @@ -4597,10 +4605,10 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/migration/tree/fix-migration-sentry-leak", + "source": "https://github.com/utopia-php/migration/tree/1.10.1", "issues": "https://github.com/utopia-php/migration/issues" }, - "time": "2026-05-05T15:50:58+00:00" + "time": "2026-05-07T07:23:57+00:00" }, { "name": "utopia-php/mongo", @@ -8461,18 +8469,9 @@ "time": "2024-11-07T12:36:22+00:00" } ], - "aliases": [ - { - "package": "utopia-php/migration", - "version": "dev-fix-migration-sentry-leak", - "alias": "1.9.999", - "alias_normalized": "1.9.999.0" - } - ], + "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "utopia-php/migration": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { From 4721523ea8eedd501fbe11f397b0dcd469d17062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 10:30:56 +0200 Subject: [PATCH 055/115] Improve project tests --- .../Projects/ProjectsConsoleClientTest.php | 430 +++++++++++++++++- 1 file changed, 428 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 3a9037c368..f3643f0959 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -787,12 +787,117 @@ class ProjectsConsoleClientTest extends Scope 'projectId' => ID::unique(), 'name' => 'Project Test', 'teamId' => $team['body']['$id'], - 'region' => System::getEnv('_APP_REGION', 'default') + 'region' => System::getEnv('_APP_REGION', 'default'), + 'description' => 'My description', + 'logo' => 'https://google.com/logo.png', + 'url' => 'https://myapp.com/', + 'legalName' => 'Legal company', + 'legalCountry' => 'Slovakia', + 'legalState' => 'Custom state', + 'legalCity' => 'Košice', + 'legalAddress' => 'Main street 32', + 'legalTaxId' => 'TAXID_123456' ]); - + $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; + // Add mock numbers + $response = $this->client->call(Client::METHOD_POST, '/project/' . $id . '/mock-phones', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'number' => '+421123456789', + 'otp' => '123456' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/project/' . $id . '/mock-phones', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'number' => '+420987654321', + 'otp' => '654321' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Setup custom values for project policies + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-duration', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'duration' => 135 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/user-limit', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => 54 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-limit', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => 7 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-history', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'total' => 9 + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-dictionary', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-personal-data', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-alert', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => true + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/membership-privacy', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => true, + 'userEmail' => true, + 'userPhone' => true, + 'userName' => true, + 'userMFA' => true, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-invalidation', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + /** * Test for SUCCESS */ @@ -806,6 +911,327 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals($id, $response['body']['$id']); $this->assertEquals('Project Test', $response['body']['name']); + $this->assertArrayHasKey('$createdAt', $response); + $this->assertIsString($response['$createdAt']); + $this->assertNotFalse(\strtotime($response['$createdAt'])); + + $this->assertArrayHasKey('$updatedAt', $response); + $this->assertIsString($response['$updatedAt']); + $this->assertNotFalse(\strtotime($response['$updatedAt'])); + + $this->assertArrayHasKey('description', $response); + $this->assertIsString($response['description']); + $this->assertEquals('My description', $response['description']); + + $this->assertArrayHasKey('teamId', $response); + $this->assertIsString($response['teamId']); + $this->assertEquals($team['body']['$id'], $response['teamId']); + + $this->assertArrayHasKey('logo', $response); + $this->assertIsString($response['logo']); + $this->assertEquals('https://google.com/logo.png', $response['logo']); + + $this->assertArrayHasKey('url', $response); + $this->assertIsString($response['url']); + $this->assertEquals('https://myapp.com/', $response['url']); + + $this->assertArrayHasKey('legalName', $response); + $this->assertIsString($response['legalName']); + $this->assertEquals('Legal company', $response['legalName']); + + $this->assertArrayHasKey('legalCountry', $response); + $this->assertIsString($response['legalCountry']); + $this->assertEquals('Slovakia', $response['legalCountry']); + + $this->assertArrayHasKey('legalState', $response); + $this->assertIsString($response['legalState']); + $this->assertEquals('Custom state', $response['legalState']); + + $this->assertArrayHasKey('legalCity', $response); + $this->assertIsString($response['legalCity']); + $this->assertEquals('Košice', $response['legalCity']); + + $this->assertArrayHasKey('legalAddress', $response); + $this->assertIsString($response['legalAddress']); + $this->assertEquals('Main street 32', $response['legalAddress']); + + $this->assertArrayHasKey('legalTaxId', $response); + $this->assertIsString($response['legalTaxId']); + $this->assertEquals('TAXID_123456', $response['legalTaxId']); + + $this->assertArrayHasKey('authDuration', $response); + $this->assertIsInt($response['authDuration']); + $this->assertEquals(135, $response['authDuration']); + + $this->assertArrayHasKey('authLimit', $response); + $this->assertIsInt($response['authLimit']); + $this->assertEquals(54, $response['authLimit']); + + $this->assertArrayHasKey('authSessionsLimit', $response); + $this->assertIsInt($response['authSessionsLimit']); + $this->assertEquals(7, $response['authLimit']); + + $this->assertArrayHasKey('authPasswordHistory', $response); + $this->assertIsInt($response['authPasswordHistory']); + $this->assertEquals(9, $response['authPasswordHistory']); + + $this->assertArrayHasKey('authPasswordDictionary', $response); + $this->assertIsBool($response['authPasswordDictionary']); + $this->assertTrue($response['authPasswordDictionary']); + + $this->assertArrayHasKey('authPersonalDataCheck', $response); + $this->assertIsBool($response['authPersonalDataCheck']); + $this->assertTrue($response['authPersonalDataCheck']); + + $this->assertArrayHasKey('authDisposableEmails', $response); + $this->assertIsBool($response['authDisposableEmails']); + $this->assertFalse($response['authDisposableEmails']); + + $this->assertArrayHasKey('authCanonicalEmails', $response); + $this->assertIsBool($response['authCanonicalEmails']); + $this->assertFalse($response['authCanonicalEmails']); + + $this->assertArrayHasKey('authFreeEmails', $response); + $this->assertIsBool($response['authFreeEmails']); + $this->assertFalse($response['authFreeEmails']); + + $this->assertArrayHasKey('authMockNumbers', $response); + $this->assertIsArray($response['authMockNumbers']); + $this->assertCount(2, $response['authMockNumbers']); + + $this->assertEquals('+421123456789', $response['authMockNumbers'][0]['number']); + $this->assertEquals('+420987654321', $response['authMockNumbers'][1]['number']); + + $this->assertEquals('123456', $response['authMockNumbers'][0]['otp']); + $this->assertEquals('654321', $response['authMockNumbers'][1]['otp']); + + foreach($response['authMockNumbers'] as $mockNumber) { + $this->assertArrayHasKey('$createdAt', $mockNumber); + $this->assertIsString($mockNumber['$createdAt']); + $this->assertNotFalse(\strtotime($mockNumber['$createdAt'])); + + $this->assertArrayHasKey('$updatedAt', $mockNumber); + $this->assertIsString($mockNumber['$updatedAt']); + $this->assertNotFalse(\strtotime($mockNumber['$updatedAt'])); + + $this->assertArrayHasKey('number', $mockNumber); + $this->assertIsString($mockNumber['number']); + $this->assertNotEmpty($mockNumber['number']); + + $this->assertArrayHasKey('otp', $mockNumber); + $this->assertIsString($mockNumber['otp']); + $this->assertNotEmpty($mockNumber['otp']); + } + + $this->assertArrayHasKey('authSessionAlerts', $response); + $this->assertIsBool($response['authSessionAlerts']); + $this->assertTrue($response['authSessionAlerts']); + + $this->assertArrayHasKey('authMembershipsUserName', $response); + $this->assertIsBool($response['authMembershipsUserName']); + $this->assertTrue($response['authMembershipsUserName']); + + $this->assertArrayHasKey('authMembershipsUserEmail', $response); + $this->assertIsBool($response['authMembershipsUserEmail']); + $this->assertTrue($response['authMembershipsUserEmail']); + + $this->assertArrayHasKey('authMembershipsMfa', $response); + $this->assertIsBool($response['authMembershipsMfa']); + $this->assertTrue($response['authMembershipsMfa']); + + $this->assertArrayHasKey('authMembershipsUserId', $response); + $this->assertIsBool($project['authMembershipsUserId']); + $this->assertTrue($response['authMembershipsUserId']); + + $this->assertArrayHasKey('authMembershipsUserPhone', $response); + $this->assertIsBool($response['authMembershipsUserPhone']); + $this->assertTrue($response['authMembershipsUserPhone']); + + $this->assertArrayHasKey('authInvalidateSessions', $response); + $this->assertIsBool($response['authInvalidateSessions']); + $this->assertTrue($response['authInvalidateSessions']); + + /* + $this->assertArrayHasKey('oAuthProviders', $project); + $this->assertIsArray($project['oAuthProviders']); + + $this->assertArrayHasKey('platforms', $project); + $this->assertIsArray($project['platforms']); + + $this->assertArrayHasKey('webhooks', $project); + $this->assertIsArray($project['webhooks']); + + $this->assertArrayHasKey('keys', $project); + $this->assertIsArray($project['keys']); + + $this->assertArrayHasKey('devKeys', $project); + $this->assertIsArray($project['devKeys']); + */ + + /* + $this->assertArrayHasKey('smtpEnabled', $project); + $this->assertIsBool($project['smtpEnabled']); + + $this->assertArrayHasKey('smtpSenderName', $project); + $this->assertIsString($project['smtpSenderName']); + + $this->assertArrayHasKey('smtpSenderEmail', $project); + $this->assertIsString($project['smtpSenderEmail']); + + $this->assertArrayHasKey('smtpReplyToName', $project); + $this->assertIsString($project['smtpReplyToName']); + + $this->assertArrayHasKey('smtpReplyToEmail', $project); + $this->assertIsString($project['smtpReplyToEmail']); + + $this->assertArrayHasKey('smtpHost', $project); + $this->assertIsString($project['smtpHost']); + + $this->assertArrayHasKey('smtpPort', $project); + $this->assertTrue( + is_int($project['smtpPort']) || $project['smtpPort'] === '', + 'smtpPort should be an integer or an empty string' + ); + + $this->assertArrayHasKey('smtpUsername', $project); + $this->assertIsString($project['smtpUsername']); + + $this->assertArrayHasKey('smtpPassword', $project); + $this->assertIsString($project['smtpPassword']); + + $this->assertArrayHasKey('smtpSecure', $project); + $this->assertIsString($project['smtpSecure']); + */ + + + /* + $this->assertArrayHasKey('pingCount', $project); + $this->assertIsInt($project['pingCount']); + + $this->assertArrayHasKey('pingedAt', $project); + $this->assertIsString($project['pingedAt']); + + $this->assertArrayHasKey('labels', $project); + $this->assertIsArray($project['labels']); + + $this->assertArrayHasKey('status', $project); + $this->assertIsString($project['status']); + */ + + /* + $auth = require(__DIR__ . '/../../../../app/config/auth.php'); + foreach ($auth as $method) { + $key = 'auth' . ucfirst($method['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing auth field: ' . $key); + $this->assertIsBool($project[$key], 'Auth field should be boolean: ' . $key); + } + + $services = require(__DIR__ . '/../../../../app/config/services.php'); + foreach ($services as $service) { + if (!($service['optional'] ?? false)) { + continue; + } + $key = 'serviceStatusFor' . ucfirst($service['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing service field: ' . $key); + $this->assertIsBool($project[$key], 'Service field should be boolean: ' . $key); + } + + // Dynamic protocol status fields + $protocols = require(__DIR__ . '/../../../../app/config/protocols.php'); + foreach ($protocols as $protocol) { + $key = 'protocolStatusFor' . ucfirst($protocol['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing protocol field: ' . $key); + $this->assertIsBool($project[$key], 'Protocol field should be boolean: ' . $key); + } + */ + + // Ensure policies can be falsy + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-dictionary', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-personal-data', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-alert', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/membership-privacy', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'userId' => false, + 'userEmail' => false, + 'userPhone' => false, + 'userName' => false, + 'userMFA' => false, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-invalidation', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertArrayHasKey('authPasswordDictionary', $response); + $this->assertIsBool($response['authPasswordDictionary']); + $this->assertFalse($response['authPasswordDictionary']); + + $this->assertArrayHasKey('authPersonalDataCheck', $response); + $this->assertIsBool($response['authPersonalDataCheck']); + $this->assertFalse($response['authPersonalDataCheck']); + + $this->assertArrayHasKey('authSessionAlerts', $response); + $this->assertIsBool($response['authSessionAlerts']); + $this->assertFalse($response['authSessionAlerts']); + + $this->assertArrayHasKey('authMembershipsUserName', $response); + $this->assertIsBool($response['authMembershipsUserName']); + $this->assertFalse($response['authMembershipsUserName']); + + $this->assertArrayHasKey('authMembershipsUserEmail', $response); + $this->assertIsBool($response['authMembershipsUserEmail']); + $this->assertFalse($response['authMembershipsUserEmail']); + + $this->assertArrayHasKey('authMembershipsMfa', $response); + $this->assertIsBool($response['authMembershipsMfa']); + $this->assertFalse($response['authMembershipsMfa']); + + $this->assertArrayHasKey('authMembershipsUserId', $response); + $this->assertIsBool($project['authMembershipsUserId']); + $this->assertFalse($response['authMembershipsUserId']); + + $this->assertArrayHasKey('authMembershipsUserPhone', $response); + $this->assertIsBool($response['authMembershipsUserPhone']); + $this->assertFalse($response['authMembershipsUserPhone']); + + $this->assertArrayHasKey('authInvalidateSessions', $response); + $this->assertIsBool($response['authInvalidateSessions']); + $this->assertFalse($response['authInvalidateSessions']); + /** * Test for FAILURE */ From 8ad106632e1e1442cde72726864137d3c2863079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 10:40:54 +0200 Subject: [PATCH 056/115] Improve tests further --- phpunit.xml | 2 +- .../Projects/ProjectsConsoleClientTest.php | 181 ++++++++++++------ 2 files changed, 128 insertions(+), 55 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 9748c5a5c8..b566e232cd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,7 +5,7 @@ bootstrap="app/init.php" colors="true" processIsolation="false" - stopOnFailure="false" + stopOnFailure="true" stopOnError="false" cacheDirectory=".phpunit.cache" > diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f3643f0959..6ed45e36df 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -802,19 +802,41 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; + // Configure SMTP + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ], $this->getHeaders()), + [ + 'enabled' => true, + 'senderName' => 'Custom sender', + 'senderEmail' => 'email@custom.com', + 'host' => 'maildev', + 'port' => 1025, + 'replyTo' => 'replyto@custom.com', + 'replyToName' => 'Reply sender', + ], + ); + // Add mock numbers - $response = $this->client->call(Client::METHOD_POST, '/project/' . $id . '/mock-phones', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'number' => '+421123456789', 'otp' => '123456' ]); + \var_dump($id); + \var_dump($this->getHeaders()); $this->assertEquals(201, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_POST, '/project/' . $id . '/mock-phones', array_merge([ + $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id ], $this->getHeaders()), [ 'number' => '+420987654321', 'otp' => '654321' @@ -822,65 +844,65 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); // Setup custom values for project policies - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-duration', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id ], $this->getHeaders()), [ 'duration' => 135 ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/user-limit', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'total' => 54 ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-limit', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'total' => 7 ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-history', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'total' => 9 ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-dictionary', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'enabled' => true ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-personal-data', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'enabled' => true ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-alert', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'enabled' => true ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/membership-privacy', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'userId' => true, 'userEmail' => true, @@ -890,9 +912,9 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-invalidation', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, ], $this->getHeaders()), [ 'enabled' => false ]); @@ -1051,6 +1073,46 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsBool($response['authInvalidateSessions']); $this->assertTrue($response['authInvalidateSessions']); + $this->assertArrayHasKey('smtpEnabled', $response); + $this->assertIsBool($response['smtpEnabled']); + $this->assertTrue($response['smtpEnabled']); + + $this->assertArrayHasKey('smtpSenderName', $response); + $this->assertIsString($response['smtpSenderName']); + $this->assertSame('Custom sender', $response['smtpSenderName']); + + $this->assertArrayHasKey('smtpSenderEmail', $response); + $this->assertIsString($response['smtpSenderEmail']); + $this->assertSame('email@custom.com', $response['smtpSenderEmail']); + + $this->assertArrayHasKey('smtpReplyToName', $response); + $this->assertIsString($response['smtpReplyToName']); + $this->assertSame('Reply sender', $response['smtpReplyToName']); + + $this->assertArrayHasKey('smtpReplyToEmail', $response); + $this->assertIsString($response['smtpReplyToEmail']); + $this->assertSame('replyto@custom.com', $response['smtpReplyToEmail']); + + $this->assertArrayHasKey('smtpHost', $response); + $this->assertIsString($response['smtpHost']); + $this->assertSame('maildev', $response['smtpHost']); + + $this->assertArrayHasKey('smtpPort', $response); + $this->assertIsInt($response['smtpPort']); + $this->assertSame(1025, $response['smtpPort']); + + $this->assertArrayHasKey('smtpUsername', $response); + $this->assertIsString($response['smtpUsername']); + $this->assertSame('', $response['smtpUsername']); + + $this->assertArrayHasKey('smtpPassword', $response); + $this->assertIsString($response['smtpPassword']); + $this->assertSame('', $response['smtpPassword']); + + $this->assertArrayHasKey('smtpSecure', $response); + $this->assertIsString($response['smtpSecure']); + $this->assertSame('', $response['smtpSecure']); + /* $this->assertArrayHasKey('oAuthProviders', $project); $this->assertIsArray($project['oAuthProviders']); @@ -1069,38 +1131,7 @@ class ProjectsConsoleClientTest extends Scope */ /* - $this->assertArrayHasKey('smtpEnabled', $project); - $this->assertIsBool($project['smtpEnabled']); - - $this->assertArrayHasKey('smtpSenderName', $project); - $this->assertIsString($project['smtpSenderName']); - - $this->assertArrayHasKey('smtpSenderEmail', $project); - $this->assertIsString($project['smtpSenderEmail']); - - $this->assertArrayHasKey('smtpReplyToName', $project); - $this->assertIsString($project['smtpReplyToName']); - - $this->assertArrayHasKey('smtpReplyToEmail', $project); - $this->assertIsString($project['smtpReplyToEmail']); - - $this->assertArrayHasKey('smtpHost', $project); - $this->assertIsString($project['smtpHost']); - - $this->assertArrayHasKey('smtpPort', $project); - $this->assertTrue( - is_int($project['smtpPort']) || $project['smtpPort'] === '', - 'smtpPort should be an integer or an empty string' - ); - - $this->assertArrayHasKey('smtpUsername', $project); - $this->assertIsString($project['smtpUsername']); - - $this->assertArrayHasKey('smtpPassword', $project); - $this->assertIsString($project['smtpPassword']); - - $this->assertArrayHasKey('smtpSecure', $project); - $this->assertIsString($project['smtpSecure']); + */ @@ -1190,6 +1221,24 @@ class ProjectsConsoleClientTest extends Scope 'enabled' => false ]); $this->assertEquals(200, $response['headers']['status-code']); + + // Configure SMTP + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/smtp', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), + [ + 'enabled' => false, + 'host' => 'customhost.com', + 'port' => 4444, + 'username' => 'myuser', + 'password' => 'mypassword', + 'secure' => 'ssl', + ], + ); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', @@ -1232,6 +1281,30 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsBool($response['authInvalidateSessions']); $this->assertFalse($response['authInvalidateSessions']); + $this->assertArrayHasKey('smtpEnabled', $response); + $this->assertIsBool($response['smtpEnabled']); + $this->assertFalse($response['smtpEnabled']); + + $this->assertArrayHasKey('smtpHost', $response); + $this->assertIsString($response['smtpHost']); + $this->assertSame('customhost.com', $response['smtpHost']); + + $this->assertArrayHasKey('smtpPort', $response); + $this->assertIsInt($response['smtpPort']); + $this->assertSame(4444, $response['smtpPort']); + + $this->assertArrayHasKey('smtpUsername', $response); + $this->assertIsString($response['smtpUsername']); + $this->assertSame('myuser', $response['smtpUsername']); + + $this->assertArrayHasKey('smtpPassword', $response); + $this->assertIsString($response['smtpPassword']); + $this->assertSame('', $response['smtpPassword']); + + $this->assertArrayHasKey('smtpSecure', $response); + $this->assertIsString($response['smtpSecure']); + $this->assertSame('ssl', $response['smtpSecure']); + /** * Test for FAILURE */ From fe27ae058406963ad8f1ec9fd3c2b8a076fface6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 10:58:05 +0200 Subject: [PATCH 057/115] Fix failing tests --- .../Projects/ProjectsConsoleClientTest.php | 405 ++++++------------ 1 file changed, 129 insertions(+), 276 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 6ed45e36df..a8bfaa0f7f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -798,7 +798,7 @@ class ProjectsConsoleClientTest extends Scope 'legalAddress' => 'Main street 32', 'legalTaxId' => 'TAXID_123456' ]); - + $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; @@ -809,6 +809,7 @@ class ProjectsConsoleClientTest extends Scope array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => true, @@ -816,10 +817,11 @@ class ProjectsConsoleClientTest extends Scope 'senderEmail' => 'email@custom.com', 'host' => 'maildev', 'port' => 1025, - 'replyTo' => 'replyto@custom.com', + 'replyToEmail' => 'replyto@custom.com', 'replyToName' => 'Reply sender', ], ); + $this->assertEquals(200, $response['headers']['status-code']); // Add mock numbers $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ @@ -830,13 +832,12 @@ class ProjectsConsoleClientTest extends Scope 'number' => '+421123456789', 'otp' => '123456' ]); - \var_dump($id); - \var_dump($this->getHeaders()); $this->assertEquals(201, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $id + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'number' => '+420987654321', 'otp' => '654321' @@ -846,15 +847,17 @@ class ProjectsConsoleClientTest extends Scope // Setup custom values for project policies $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-duration', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $id + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'duration' => 135 ]); $this->assertEquals(200, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/user-limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'total' => 54 ]); @@ -863,22 +866,25 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-limit', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'total' => 7 ]); $this->assertEquals(200, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-history', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'total' => 9 ]); $this->assertEquals(200, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => true ]); @@ -887,6 +893,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => true ]); @@ -894,7 +901,8 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $id, + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => true ]); @@ -903,6 +911,7 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'userId' => true, 'userEmail' => true, @@ -915,8 +924,9 @@ class ProjectsConsoleClientTest extends Scope $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ - 'enabled' => false + 'enabled' => true ]); $this->assertEquals(200, $response['headers']['status-code']); @@ -929,190 +939,76 @@ class ProjectsConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertNotEmpty($response['body']); $this->assertEquals($id, $response['body']['$id']); $this->assertEquals('Project Test', $response['body']['name']); - $this->assertArrayHasKey('$createdAt', $response); - $this->assertIsString($response['$createdAt']); - $this->assertNotFalse(\strtotime($response['$createdAt'])); + $this->assertIsString($response['body']['$createdAt']); + $this->assertNotEmpty($response['body']['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['$createdAt'])); - $this->assertArrayHasKey('$updatedAt', $response); - $this->assertIsString($response['$updatedAt']); - $this->assertNotFalse(\strtotime($response['$updatedAt'])); + $this->assertIsString($response['body']['$updatedAt']); + $this->assertNotEmpty($response['body']['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['$updatedAt'])); - $this->assertArrayHasKey('description', $response); - $this->assertIsString($response['description']); - $this->assertEquals('My description', $response['description']); + $this->assertEquals('My description', $response['body']['description']); + $this->assertEquals($team['body']['$id'], $response['body']['teamId']); + $this->assertEquals('https://google.com/logo.png', $response['body']['logo']); + $this->assertEquals('https://myapp.com/', $response['body']['url']); + $this->assertEquals('Legal company', $response['body']['legalName']); + $this->assertEquals('Slovakia', $response['body']['legalCountry']); + $this->assertEquals('Custom state', $response['body']['legalState']); + $this->assertEquals('Košice', $response['body']['legalCity']); + $this->assertEquals('Main street 32', $response['body']['legalAddress']); + $this->assertEquals('TAXID_123456', $response['body']['legalTaxId']); + $this->assertEquals(135, $response['body']['authDuration']); + $this->assertEquals(54, $response['body']['authLimit']); + $this->assertEquals(7, $response['body']['authSessionsLimit']); + $this->assertEquals(9, $response['body']['authPasswordHistory']); + $this->assertTrue($response['body']['authPasswordDictionary']); + $this->assertTrue($response['body']['authPersonalDataCheck']); + $this->assertFalse($response['body']['authDisposableEmails']); + $this->assertFalse($response['body']['authCanonicalEmails']); + $this->assertFalse($response['body']['authFreeEmails']); + $this->assertTrue($response['body']['authSessionAlerts']); + $this->assertTrue($response['body']['authMembershipsUserName']); + $this->assertTrue($response['body']['authMembershipsUserEmail']); + $this->assertTrue($response['body']['authMembershipsMfa']); + $this->assertTrue($response['body']['authMembershipsUserId']); + $this->assertTrue($response['body']['authMembershipsUserPhone']); + $this->assertTrue($response['body']['authInvalidateSessions']); + $this->assertTrue($response['body']['smtpEnabled']); + $this->assertSame('Custom sender', $response['body']['smtpSenderName']); + $this->assertSame('email@custom.com', $response['body']['smtpSenderEmail']); + $this->assertSame('Reply sender', $response['body']['smtpReplyToName']); + $this->assertSame('replyto@custom.com', $response['body']['smtpReplyToEmail']); + $this->assertSame('maildev', $response['body']['smtpHost']); + $this->assertSame(1025, $response['body']['smtpPort']); + $this->assertSame('', $response['body']['smtpUsername']); + $this->assertSame('', $response['body']['smtpPassword']); // Write only + $this->assertSame('', $response['body']['smtpSecure']); - $this->assertArrayHasKey('teamId', $response); - $this->assertIsString($response['teamId']); - $this->assertEquals($team['body']['$id'], $response['teamId']); + $this->assertCount(2, $response['body']['authMockNumbers']); + $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']); + $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']); + $this->assertEquals('123456', $response['body']['authMockNumbers'][0]['otp']); + $this->assertEquals('654321', $response['body']['authMockNumbers'][1]['otp']); - $this->assertArrayHasKey('logo', $response); - $this->assertIsString($response['logo']); - $this->assertEquals('https://google.com/logo.png', $response['logo']); - - $this->assertArrayHasKey('url', $response); - $this->assertIsString($response['url']); - $this->assertEquals('https://myapp.com/', $response['url']); - - $this->assertArrayHasKey('legalName', $response); - $this->assertIsString($response['legalName']); - $this->assertEquals('Legal company', $response['legalName']); - - $this->assertArrayHasKey('legalCountry', $response); - $this->assertIsString($response['legalCountry']); - $this->assertEquals('Slovakia', $response['legalCountry']); - - $this->assertArrayHasKey('legalState', $response); - $this->assertIsString($response['legalState']); - $this->assertEquals('Custom state', $response['legalState']); - - $this->assertArrayHasKey('legalCity', $response); - $this->assertIsString($response['legalCity']); - $this->assertEquals('Košice', $response['legalCity']); - - $this->assertArrayHasKey('legalAddress', $response); - $this->assertIsString($response['legalAddress']); - $this->assertEquals('Main street 32', $response['legalAddress']); - - $this->assertArrayHasKey('legalTaxId', $response); - $this->assertIsString($response['legalTaxId']); - $this->assertEquals('TAXID_123456', $response['legalTaxId']); - - $this->assertArrayHasKey('authDuration', $response); - $this->assertIsInt($response['authDuration']); - $this->assertEquals(135, $response['authDuration']); - - $this->assertArrayHasKey('authLimit', $response); - $this->assertIsInt($response['authLimit']); - $this->assertEquals(54, $response['authLimit']); - - $this->assertArrayHasKey('authSessionsLimit', $response); - $this->assertIsInt($response['authSessionsLimit']); - $this->assertEquals(7, $response['authLimit']); - - $this->assertArrayHasKey('authPasswordHistory', $response); - $this->assertIsInt($response['authPasswordHistory']); - $this->assertEquals(9, $response['authPasswordHistory']); - - $this->assertArrayHasKey('authPasswordDictionary', $response); - $this->assertIsBool($response['authPasswordDictionary']); - $this->assertTrue($response['authPasswordDictionary']); - - $this->assertArrayHasKey('authPersonalDataCheck', $response); - $this->assertIsBool($response['authPersonalDataCheck']); - $this->assertTrue($response['authPersonalDataCheck']); - - $this->assertArrayHasKey('authDisposableEmails', $response); - $this->assertIsBool($response['authDisposableEmails']); - $this->assertFalse($response['authDisposableEmails']); - - $this->assertArrayHasKey('authCanonicalEmails', $response); - $this->assertIsBool($response['authCanonicalEmails']); - $this->assertFalse($response['authCanonicalEmails']); - - $this->assertArrayHasKey('authFreeEmails', $response); - $this->assertIsBool($response['authFreeEmails']); - $this->assertFalse($response['authFreeEmails']); - - $this->assertArrayHasKey('authMockNumbers', $response); - $this->assertIsArray($response['authMockNumbers']); - $this->assertCount(2, $response['authMockNumbers']); - - $this->assertEquals('+421123456789', $response['authMockNumbers'][0]['number']); - $this->assertEquals('+420987654321', $response['authMockNumbers'][1]['number']); - - $this->assertEquals('123456', $response['authMockNumbers'][0]['otp']); - $this->assertEquals('654321', $response['authMockNumbers'][1]['otp']); - - foreach($response['authMockNumbers'] as $mockNumber) { - $this->assertArrayHasKey('$createdAt', $mockNumber); + foreach ($response['body']['authMockNumbers'] as $mockNumber) { $this->assertIsString($mockNumber['$createdAt']); + $this->assertNotEmpty($mockNumber['$createdAt']); $this->assertNotFalse(\strtotime($mockNumber['$createdAt'])); - - $this->assertArrayHasKey('$updatedAt', $mockNumber); + $this->assertIsString($mockNumber['$updatedAt']); + $this->assertNotEmpty($mockNumber['$updatedAt']); $this->assertNotFalse(\strtotime($mockNumber['$updatedAt'])); - $this->assertArrayHasKey('number', $mockNumber); - $this->assertIsString($mockNumber['number']); - $this->assertNotEmpty($mockNumber['number']); - - $this->assertArrayHasKey('otp', $mockNumber); + $this->assertIsString($mockNumber['phone']); + $this->assertNotEmpty($mockNumber['phone']); + $this->assertIsString($mockNumber['otp']); $this->assertNotEmpty($mockNumber['otp']); } - $this->assertArrayHasKey('authSessionAlerts', $response); - $this->assertIsBool($response['authSessionAlerts']); - $this->assertTrue($response['authSessionAlerts']); - - $this->assertArrayHasKey('authMembershipsUserName', $response); - $this->assertIsBool($response['authMembershipsUserName']); - $this->assertTrue($response['authMembershipsUserName']); - - $this->assertArrayHasKey('authMembershipsUserEmail', $response); - $this->assertIsBool($response['authMembershipsUserEmail']); - $this->assertTrue($response['authMembershipsUserEmail']); - - $this->assertArrayHasKey('authMembershipsMfa', $response); - $this->assertIsBool($response['authMembershipsMfa']); - $this->assertTrue($response['authMembershipsMfa']); - - $this->assertArrayHasKey('authMembershipsUserId', $response); - $this->assertIsBool($project['authMembershipsUserId']); - $this->assertTrue($response['authMembershipsUserId']); - - $this->assertArrayHasKey('authMembershipsUserPhone', $response); - $this->assertIsBool($response['authMembershipsUserPhone']); - $this->assertTrue($response['authMembershipsUserPhone']); - - $this->assertArrayHasKey('authInvalidateSessions', $response); - $this->assertIsBool($response['authInvalidateSessions']); - $this->assertTrue($response['authInvalidateSessions']); - - $this->assertArrayHasKey('smtpEnabled', $response); - $this->assertIsBool($response['smtpEnabled']); - $this->assertTrue($response['smtpEnabled']); - - $this->assertArrayHasKey('smtpSenderName', $response); - $this->assertIsString($response['smtpSenderName']); - $this->assertSame('Custom sender', $response['smtpSenderName']); - - $this->assertArrayHasKey('smtpSenderEmail', $response); - $this->assertIsString($response['smtpSenderEmail']); - $this->assertSame('email@custom.com', $response['smtpSenderEmail']); - - $this->assertArrayHasKey('smtpReplyToName', $response); - $this->assertIsString($response['smtpReplyToName']); - $this->assertSame('Reply sender', $response['smtpReplyToName']); - - $this->assertArrayHasKey('smtpReplyToEmail', $response); - $this->assertIsString($response['smtpReplyToEmail']); - $this->assertSame('replyto@custom.com', $response['smtpReplyToEmail']); - - $this->assertArrayHasKey('smtpHost', $response); - $this->assertIsString($response['smtpHost']); - $this->assertSame('maildev', $response['smtpHost']); - - $this->assertArrayHasKey('smtpPort', $response); - $this->assertIsInt($response['smtpPort']); - $this->assertSame(1025, $response['smtpPort']); - - $this->assertArrayHasKey('smtpUsername', $response); - $this->assertIsString($response['smtpUsername']); - $this->assertSame('', $response['smtpUsername']); - - $this->assertArrayHasKey('smtpPassword', $response); - $this->assertIsString($response['smtpPassword']); - $this->assertSame('', $response['smtpPassword']); - - $this->assertArrayHasKey('smtpSecure', $response); - $this->assertIsString($response['smtpSecure']); - $this->assertSame('', $response['smtpSecure']); - /* $this->assertArrayHasKey('oAuthProviders', $project); $this->assertIsArray($project['oAuthProviders']); @@ -1130,12 +1026,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsArray($project['devKeys']); */ - /* - - */ - - - /* + /* $this->assertArrayHasKey('pingCount', $project); $this->assertIsInt($project['pingCount']); @@ -1147,64 +1038,68 @@ class ProjectsConsoleClientTest extends Scope $this->assertArrayHasKey('status', $project); $this->assertIsString($project['status']); - */ + */ - /* + /* $auth = require(__DIR__ . '/../../../../app/config/auth.php'); foreach ($auth as $method) { - $key = 'auth' . ucfirst($method['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing auth field: ' . $key); - $this->assertIsBool($project[$key], 'Auth field should be boolean: ' . $key); + $key = 'auth' . ucfirst($method['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing auth field: ' . $key); + $this->assertIsBool($project[$key], 'Auth field should be boolean: ' . $key); } $services = require(__DIR__ . '/../../../../app/config/services.php'); foreach ($services as $service) { - if (!($service['optional'] ?? false)) { - continue; - } - $key = 'serviceStatusFor' . ucfirst($service['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing service field: ' . $key); - $this->assertIsBool($project[$key], 'Service field should be boolean: ' . $key); + if (!($service['optional'] ?? false)) { + continue; + } + $key = 'serviceStatusFor' . ucfirst($service['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing service field: ' . $key); + $this->assertIsBool($project[$key], 'Service field should be boolean: ' . $key); } // Dynamic protocol status fields $protocols = require(__DIR__ . '/../../../../app/config/protocols.php'); foreach ($protocols as $protocol) { - $key = 'protocolStatusFor' . ucfirst($protocol['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing protocol field: ' . $key); - $this->assertIsBool($project[$key], 'Protocol field should be boolean: ' . $key); + $key = 'protocolStatusFor' . ucfirst($protocol['key'] ?? ''); + $this->assertArrayHasKey($key, $project, 'Missing protocol field: ' . $key); + $this->assertIsBool($project[$key], 'Protocol field should be boolean: ' . $key); } */ // Ensure policies can be falsy - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-dictionary', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => false ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/password-personal-data', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-personal-data', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'enabled' => false - ]); - $this->assertEquals(200, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-alert', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => false ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/membership-privacy', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-alert', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'enabled' => false + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/membership-privacy', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'userId' => false, 'userEmail' => false, @@ -1214,9 +1109,10 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PATCH, '/project/' . $id . '/policies/session-invalidation', array_merge([ + $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/session-invalidation', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => false ]); @@ -1228,7 +1124,8 @@ class ProjectsConsoleClientTest extends Scope '/project/smtp', array_merge([ 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ 'enabled' => false, @@ -1239,71 +1136,27 @@ class ProjectsConsoleClientTest extends Scope 'secure' => 'ssl', ], ); - + $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); - - $this->assertArrayHasKey('authPasswordDictionary', $response); - $this->assertIsBool($response['authPasswordDictionary']); - $this->assertFalse($response['authPasswordDictionary']); - $this->assertArrayHasKey('authPersonalDataCheck', $response); - $this->assertIsBool($response['authPersonalDataCheck']); - $this->assertFalse($response['authPersonalDataCheck']); - - $this->assertArrayHasKey('authSessionAlerts', $response); - $this->assertIsBool($response['authSessionAlerts']); - $this->assertFalse($response['authSessionAlerts']); - - $this->assertArrayHasKey('authMembershipsUserName', $response); - $this->assertIsBool($response['authMembershipsUserName']); - $this->assertFalse($response['authMembershipsUserName']); - - $this->assertArrayHasKey('authMembershipsUserEmail', $response); - $this->assertIsBool($response['authMembershipsUserEmail']); - $this->assertFalse($response['authMembershipsUserEmail']); - - $this->assertArrayHasKey('authMembershipsMfa', $response); - $this->assertIsBool($response['authMembershipsMfa']); - $this->assertFalse($response['authMembershipsMfa']); - - $this->assertArrayHasKey('authMembershipsUserId', $response); - $this->assertIsBool($project['authMembershipsUserId']); - $this->assertFalse($response['authMembershipsUserId']); - - $this->assertArrayHasKey('authMembershipsUserPhone', $response); - $this->assertIsBool($response['authMembershipsUserPhone']); - $this->assertFalse($response['authMembershipsUserPhone']); - - $this->assertArrayHasKey('authInvalidateSessions', $response); - $this->assertIsBool($response['authInvalidateSessions']); - $this->assertFalse($response['authInvalidateSessions']); - - $this->assertArrayHasKey('smtpEnabled', $response); - $this->assertIsBool($response['smtpEnabled']); - $this->assertFalse($response['smtpEnabled']); - - $this->assertArrayHasKey('smtpHost', $response); - $this->assertIsString($response['smtpHost']); - $this->assertSame('customhost.com', $response['smtpHost']); - - $this->assertArrayHasKey('smtpPort', $response); - $this->assertIsInt($response['smtpPort']); - $this->assertSame(4444, $response['smtpPort']); - - $this->assertArrayHasKey('smtpUsername', $response); - $this->assertIsString($response['smtpUsername']); - $this->assertSame('myuser', $response['smtpUsername']); - - $this->assertArrayHasKey('smtpPassword', $response); - $this->assertIsString($response['smtpPassword']); - $this->assertSame('', $response['smtpPassword']); - - $this->assertArrayHasKey('smtpSecure', $response); - $this->assertIsString($response['smtpSecure']); - $this->assertSame('ssl', $response['smtpSecure']); + $this->assertFalse($response['body']['authPasswordDictionary']); + $this->assertFalse($response['body']['authPersonalDataCheck']); + $this->assertFalse($response['body']['authSessionAlerts']); + $this->assertFalse($response['body']['authMembershipsUserName']); + $this->assertFalse($response['body']['authMembershipsUserEmail']); + $this->assertFalse($response['body']['authMembershipsMfa']); + $this->assertFalse($response['body']['authMembershipsUserId']); + $this->assertFalse($response['body']['authMembershipsUserPhone']); + $this->assertFalse($response['body']['authInvalidateSessions']); + $this->assertFalse($response['body']['smtpEnabled']); + $this->assertSame('customhost.com', $response['body']['smtpHost']); + $this->assertSame(4444, $response['body']['smtpPort']); + $this->assertSame('myuser', $response['body']['smtpUsername']); + $this->assertSame('', $response['body']['smtpPassword']); // Write only + $this->assertSame('ssl', $response['body']['smtpSecure']); /** * Test for FAILURE From 38575d7620c62dae3c54c80f4331c0c5b58112f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 11:06:47 +0200 Subject: [PATCH 058/115] Improve get project tests --- .../Projects/ProjectsConsoleClientTest.php | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index a8bfaa0f7f..4ab57a3012 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1040,32 +1040,50 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsString($project['status']); */ - /* - $auth = require(__DIR__ . '/../../../../app/config/auth.php'); - foreach ($auth as $method) { - $key = 'auth' . ucfirst($method['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing auth field: ' . $key); - $this->assertIsBool($project[$key], 'Auth field should be boolean: ' . $key); + $authsKeys = [ + 'authEmailPassword', + 'authUsersAuthMagicURL', + 'authEmailOtp', + 'authAnonymous', + 'authInvites', + 'authJWT', + 'authPhone', + ]; + foreach ($authsKeys as $authsKey) { + $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); } - $services = require(__DIR__ . '/../../../../app/config/services.php'); - foreach ($services as $service) { - if (!($service['optional'] ?? false)) { - continue; - } - $key = 'serviceStatusFor' . ucfirst($service['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing service field: ' . $key); - $this->assertIsBool($project[$key], 'Service field should be boolean: ' . $key); + $serviceKeys = [ + 'serviceStatusForAccount', + 'serviceStatusForAvatars', + 'serviceStatusForDatabases', + 'serviceStatusForTablesdb', + 'serviceStatusForLocale', + 'serviceStatusForHealth', + 'serviceStatusForProject', + 'serviceStatusForStorage', + 'serviceStatusForTeams', + 'serviceStatusForUsers', + 'serviceStatusForVcs', + 'serviceStatusForSites', + 'serviceStatusForFunctions', + 'serviceStatusForProxy', + 'serviceStatusForGraphql', + 'serviceStatusForMigrations', + 'serviceStatusForMessaging', + ]; + foreach ($serviceKeys as $serviceKey) { + $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); } - // Dynamic protocol status fields - $protocols = require(__DIR__ . '/../../../../app/config/protocols.php'); - foreach ($protocols as $protocol) { - $key = 'protocolStatusFor' . ucfirst($protocol['key'] ?? ''); - $this->assertArrayHasKey($key, $project, 'Missing protocol field: ' . $key); - $this->assertIsBool($project[$key], 'Protocol field should be boolean: ' . $key); + $protocolKeys = [ + 'protocolStatusForRest', + 'protocolStatusForGraphql', + 'protocolStatusForWebsocket', + ]; + foreach ($protocolKeys as $protocolKey) { + $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); } - */ // Ensure policies can be falsy From 379a388f61d33751618b2e7a0a4196b9f6f4a618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 11:12:05 +0200 Subject: [PATCH 059/115] auth security get project tests --- .../Projects/ProjectsConsoleClientTest.php | 128 +++++++++++++++++- 1 file changed, 124 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 4ab57a3012..324d7d2bdb 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1050,7 +1050,7 @@ class ProjectsConsoleClientTest extends Scope 'authPhone', ]; foreach ($authsKeys as $authsKey) { - $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); + $this->assertTrue($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); } $serviceKeys = [ @@ -1073,7 +1073,7 @@ class ProjectsConsoleClientTest extends Scope 'serviceStatusForMessaging', ]; foreach ($serviceKeys as $serviceKey) { - $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); + $this->assertTrue($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); } $protocolKeys = [ @@ -1082,10 +1082,10 @@ class ProjectsConsoleClientTest extends Scope 'protocolStatusForWebsocket', ]; foreach ($protocolKeys as $protocolKey) { - $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); + $this->assertTrue($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); } - // Ensure policies can be falsy + // Ensure booleans can be falsy $response = $this->client->call(Client::METHOD_PATCH, '/project/policies/password-dictionary', array_merge([ 'content-type' => 'application/json', @@ -1136,6 +1136,78 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); + // Toggle auth methods, services, protocols + + $authMethods = ['email-password', 'magic-url', 'email-otp', 'anonymous', 'invites', 'jwt', 'phone']; + foreach ($authMethods as $authMethod) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/auth-methods/' . $authMethod, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + $protocols = ['rest', 'graphql', 'websocket']; + foreach ($protocols as $protocol) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/protocols/' . $protocol, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + + $services = [ + 'account', + 'avatars', + 'databases', + 'tablesdb', + 'locale', + 'health', + 'project', + 'storage', + 'teams', + 'users', + 'vcs', + 'sites', + 'functions', + 'proxy', + 'graphql', + 'migrations', + 'messaging', + ]; + + foreach ($services as $service) { + $response = $this->client->call( + Client::METHOD_PATCH, + '/project/services/' . $service, + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), + [ + 'enabled' => false, + ], + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + // Configure SMTP $response = $this->client->call( Client::METHOD_PATCH, @@ -1154,12 +1226,15 @@ class ProjectsConsoleClientTest extends Scope 'secure' => 'ssl', ], ); + $this->assertEquals(200, $response['headers']['status-code']); $response = $this->client->call(Client::METHOD_GET, '/projects/' . $id, array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertFalse($response['body']['authPasswordDictionary']); $this->assertFalse($response['body']['authPersonalDataCheck']); $this->assertFalse($response['body']['authSessionAlerts']); @@ -1176,6 +1251,51 @@ class ProjectsConsoleClientTest extends Scope $this->assertSame('', $response['body']['smtpPassword']); // Write only $this->assertSame('ssl', $response['body']['smtpSecure']); + $authsKeys = [ + 'authEmailPassword', + 'authUsersAuthMagicURL', + 'authEmailOtp', + 'authAnonymous', + 'authInvites', + 'authJWT', + 'authPhone', + ]; + foreach ($authsKeys as $authsKey) { + $this->assertFalse($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); + } + + $serviceKeys = [ + 'serviceStatusForAccount', + 'serviceStatusForAvatars', + 'serviceStatusForDatabases', + 'serviceStatusForTablesdb', + 'serviceStatusForLocale', + 'serviceStatusForHealth', + 'serviceStatusForProject', + 'serviceStatusForStorage', + 'serviceStatusForTeams', + 'serviceStatusForUsers', + 'serviceStatusForVcs', + 'serviceStatusForSites', + 'serviceStatusForFunctions', + 'serviceStatusForProxy', + 'serviceStatusForGraphql', + 'serviceStatusForMigrations', + 'serviceStatusForMessaging', + ]; + foreach ($serviceKeys as $serviceKey) { + $this->assertFalse($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); + } + + $protocolKeys = [ + 'protocolStatusForRest', + 'protocolStatusForGraphql', + 'protocolStatusForWebsocket', + ]; + foreach ($protocolKeys as $protocolKey) { + $this->assertFalse($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); + } + /** * Test for FAILURE */ From c4a941015ff453ad0090154dad96ad6b39ee6264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 11:26:55 +0200 Subject: [PATCH 060/115] Continue adding project get tests --- .../Projects/ProjectsConsoleClientTest.php | 97 +++++++++++++++---- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 324d7d2bdb..96416cbd5a 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -15,6 +15,7 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\Validator\Datetime as ValidatorDatetime; use Utopia\System\System; class ProjectsConsoleClientTest extends Scope @@ -802,6 +803,19 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(201, $response['headers']['status-code']); $id = $response['body']['$id']; + // Increase ping 3x + for ($i = 0; $i < 3; $i++) { + $response = $this->client->call( + Client::METHOD_GET, + '/ping', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + ], $this->getHeaders()), + ); + $this->assertEquals(200, $response['headers']['status-code']); + } + // Configure SMTP $response = $this->client->call( Client::METHOD_PATCH, @@ -834,6 +848,33 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(201, $response['headers']['status-code']); + // Add labels + $response = $this->client->call(Client::METHOD_PUT, '/project/labels', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'labels' => ['custom1', 'custom2'] + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Create dev keys + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([ + 'content-type' => 'application/json', + ], $this->getHeaders()), [ + 'name' => 'Custom key 1', + 'expire' => '2026-05-07 09:23:30.713', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([ + 'content-type' => 'application/json', + ], $this->getHeaders()), [ + 'name' => 'Custom key 2', + 'expire' => '2026-05-07 11:23:30.713' + ]); + $this->assertEquals(201, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_POST, '/project/mock-phones', array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $id, @@ -952,6 +993,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals('My description', $response['body']['description']); $this->assertEquals($team['body']['$id'], $response['body']['teamId']); + $this->assertEquals('active', $response['body']['status']); $this->assertEquals('https://google.com/logo.png', $response['body']['logo']); $this->assertEquals('https://myapp.com/', $response['body']['url']); $this->assertEquals('Legal company', $response['body']['legalName']); @@ -986,7 +1028,44 @@ class ProjectsConsoleClientTest extends Scope $this->assertSame('', $response['body']['smtpUsername']); $this->assertSame('', $response['body']['smtpPassword']); // Write only $this->assertSame('', $response['body']['smtpSecure']); + $this->assertSame(3, $response['body']['pingCount']); + $this->assertIsString($response['body']['pingedAt']); + $this->assertNotEmpty($response['body']['pingedAt']); + $this->assertNotFalse(\strtotime($response['body']['pingedAt'])); + + $this->assertCount(2, $response['body']['labels']); + $this->assertEquals('custom1', $response['body']['labels'][0]); + $this->assertEquals('custom2', $response['body']['labels'][1]); + + $this->assertCount(2, $response['body']['devKeys']); + $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']); + $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']); + $this->assertEquals('2026-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']); + $this->assertEquals('2026-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']); + + foreach ($response['body']['devKeys'] as $devKey) { + $this->assertIsString($devKey['$id']); + $this->assertNotEmpty($devKey['$id']); + + $this->assertIsString($devKey['secret']); + $this->assertNotEmpty($devKey['secret']); + + $this->assertIsString($devKey['accessedAt']); + $this->assertEmpty($devKey['accessedAt']); + + $this->assertIsString($devKey['$createdAt']); + $this->assertNotEmpty($devKey['$createdAt']); + $this->assertNotFalse(\strtotime($devKey['$createdAt'])); + + $this->assertIsString($devKey['$updatedAt']); + $this->assertNotEmpty($devKey['$updatedAt']); + $this->assertNotFalse(\strtotime($devKey['$updatedAt'])); + + $this->assertIsArray($devKey['sdks']); + $this->assertCount(0, $devKey['sdks']); + } + $this->assertCount(2, $response['body']['authMockNumbers']); $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']); $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']); @@ -1010,6 +1089,7 @@ class ProjectsConsoleClientTest extends Scope } /* + TODO: $this->assertArrayHasKey('oAuthProviders', $project); $this->assertIsArray($project['oAuthProviders']); @@ -1021,25 +1101,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertArrayHasKey('keys', $project); $this->assertIsArray($project['keys']); - - $this->assertArrayHasKey('devKeys', $project); - $this->assertIsArray($project['devKeys']); */ - /* - $this->assertArrayHasKey('pingCount', $project); - $this->assertIsInt($project['pingCount']); - - $this->assertArrayHasKey('pingedAt', $project); - $this->assertIsString($project['pingedAt']); - - $this->assertArrayHasKey('labels', $project); - $this->assertIsArray($project['labels']); - - $this->assertArrayHasKey('status', $project); - $this->assertIsString($project['status']); - */ - $authsKeys = [ 'authEmailPassword', 'authUsersAuthMagicURL', From 0b881ec58ae29147bbc44a2df5742f0067b9bc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 11:45:25 +0200 Subject: [PATCH 061/115] Finish test improvements for get project --- .../Projects/ProjectsConsoleClientTest.php | 149 ++++++++++++++++-- 1 file changed, 133 insertions(+), 16 deletions(-) diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 96416cbd5a..95859f8dea 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -15,7 +15,6 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; -use Utopia\Database\Validator\Datetime as ValidatorDatetime; use Utopia\System\System; class ProjectsConsoleClientTest extends Scope @@ -866,7 +865,7 @@ class ProjectsConsoleClientTest extends Scope 'expire' => '2026-05-07 09:23:30.713', ]); $this->assertEquals(201, $response['headers']['status-code']); - + $response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/dev-keys', array_merge([ 'content-type' => 'application/json', ], $this->getHeaders()), [ @@ -971,6 +970,57 @@ class ProjectsConsoleClientTest extends Scope ]); $this->assertEquals(200, $response['headers']['status-code']); + // Create webhook + $webhook = $this->client->call(Client::METHOD_POST, '/webhooks', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'webhookId' => 'unique()', + 'name' => 'Webhook Test', + 'events' => ['users.*.create', 'users.*.update.email'], + 'url' => 'https://appwrite.io', + 'tls' => true, + 'authUsername' => 'username', + 'authPassword' => 'password', + ]); + $this->assertEquals(201, $webhook['headers']['status-code']); + + // Create API key + $key = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/keys', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'keyId' => ID::unique(), + 'name' => 'Key Test', + 'scopes' => ['teams.read', 'teams.write'], + ]); + $this->assertEquals(201, $key['headers']['status-code']); + + // Create platform + $platform = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/platforms', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'platformId' => ID::unique(), + 'type' => 'web', + 'name' => 'Web App', + 'hostname' => 'localhost', + ]); + $this->assertEquals(201, $platform['headers']['status-code']); + + // Configure OAuth provider + $oauth = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $id, + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders()), [ + 'clientId' => 'github-client-id', + 'clientSecret' => 'github-client-secret', + 'enabled' => false, + ]); + $this->assertEquals(200, $oauth['headers']['status-code']); + /** * Test for SUCCESS */ @@ -1053,7 +1103,7 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsString($devKey['accessedAt']); $this->assertEmpty($devKey['accessedAt']); - + $this->assertIsString($devKey['$createdAt']); $this->assertNotEmpty($devKey['$createdAt']); $this->assertNotFalse(\strtotime($devKey['$createdAt'])); @@ -1061,11 +1111,11 @@ class ProjectsConsoleClientTest extends Scope $this->assertIsString($devKey['$updatedAt']); $this->assertNotEmpty($devKey['$updatedAt']); $this->assertNotFalse(\strtotime($devKey['$updatedAt'])); - + $this->assertIsArray($devKey['sdks']); $this->assertCount(0, $devKey['sdks']); } - + $this->assertCount(2, $response['body']['authMockNumbers']); $this->assertEquals('+421123456789', $response['body']['authMockNumbers'][0]['phone']); $this->assertEquals('+420987654321', $response['body']['authMockNumbers'][1]['phone']); @@ -1088,20 +1138,87 @@ class ProjectsConsoleClientTest extends Scope $this->assertNotEmpty($mockNumber['otp']); } - /* - TODO: - $this->assertArrayHasKey('oAuthProviders', $project); - $this->assertIsArray($project['oAuthProviders']); + $this->assertIsArray($response['body']['oAuthProviders']); + $this->assertGreaterThan(0, count($response['body']['oAuthProviders'])); - $this->assertArrayHasKey('platforms', $project); - $this->assertIsArray($project['platforms']); + $githubProvider = null; + foreach ($response['body']['oAuthProviders'] as $provider) { + $this->assertIsString($provider['key']); + $this->assertNotEmpty($provider['key']); - $this->assertArrayHasKey('webhooks', $project); - $this->assertIsArray($project['webhooks']); + $this->assertIsString($provider['name']); + $this->assertIsString($provider['appId']); + $this->assertIsString($provider['secret']); + $this->assertIsBool($provider['enabled']); - $this->assertArrayHasKey('keys', $project); - $this->assertIsArray($project['keys']); - */ + if ($provider['key'] === 'github') { + $githubProvider = $provider; + } + } + + $this->assertNotNull($githubProvider, 'GitHub provider not found'); + $this->assertEquals('github-client-id', $githubProvider['appId']); + $this->assertEquals('', $githubProvider['secret']); // Write only + $this->assertEquals(false, $githubProvider['enabled']); + + $this->assertIsArray($response['body']['platforms']); + $this->assertCount(1, $response['body']['platforms']); + $this->assertIsString($response['body']['platforms'][0]['$id']); + $this->assertNotEmpty($response['body']['platforms'][0]['$id']); + $this->assertEquals('Web App', $response['body']['platforms'][0]['name']); + $this->assertEquals('web', $response['body']['platforms'][0]['type']); + $this->assertEquals('localhost', $response['body']['platforms'][0]['hostname']); + + $this->assertIsString($response['body']['platforms'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['platforms'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$createdAt'])); + + $this->assertIsString($response['body']['platforms'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['platforms'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['platforms'][0]['$updatedAt'])); + + $this->assertArrayHasKey('webhooks', $response['body']); + $this->assertIsArray($response['body']['webhooks']); + $this->assertCount(1, $response['body']['webhooks']); + $this->assertIsString($response['body']['webhooks'][0]['$id']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$id']); + $this->assertEquals('Webhook Test', $response['body']['webhooks'][0]['name']); + $this->assertEquals('https://appwrite.io', $response['body']['webhooks'][0]['url']); + $this->assertContains('users.*.create', $response['body']['webhooks'][0]['events']); + $this->assertContains('users.*.update.email', $response['body']['webhooks'][0]['events']); + $this->assertCount(2, $response['body']['webhooks'][0]['events']); + $this->assertTrue($response['body']['webhooks'][0]['tls']); + $this->assertEquals('username', $response['body']['webhooks'][0]['authUsername']); + $this->assertEquals('password', $response['body']['webhooks'][0]['authPassword']); + $this->assertTrue($response['body']['webhooks'][0]['enabled']); + $this->assertIsString($response['body']['webhooks'][0]['secret']); + $this->assertNotEmpty($response['body']['webhooks'][0]['secret']); + $this->assertIsString($response['body']['webhooks'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$createdAt'])); + $this->assertIsString($response['body']['webhooks'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['webhooks'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['webhooks'][0]['$updatedAt'])); + + $this->assertArrayHasKey('keys', $response['body']); + $this->assertIsArray($response['body']['keys']); + $this->assertCount(1, $response['body']['keys']); + $this->assertIsString($response['body']['keys'][0]['$id']); + $this->assertNotEmpty($response['body']['keys'][0]['$id']); + $this->assertEquals('Key Test', $response['body']['keys'][0]['name']); + $this->assertContains('teams.read', $response['body']['keys'][0]['scopes']); + $this->assertContains('teams.write', $response['body']['keys'][0]['scopes']); + $this->assertCount(2, $response['body']['keys'][0]['scopes']); + $this->assertNotEmpty($response['body']['keys'][0]['secret']); + $this->assertEmpty($response['body']['keys'][0]['accessedAt']); + $this->assertIsArray($response['body']['keys'][0]['sdks']); + $this->assertCount(0, $response['body']['keys'][0]['sdks']); + $this->assertIsString($response['body']['keys'][0]['$createdAt']); + $this->assertNotEmpty($response['body']['keys'][0]['$createdAt']); + $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$createdAt'])); + $this->assertIsString($response['body']['keys'][0]['$updatedAt']); + $this->assertNotEmpty($response['body']['keys'][0]['$updatedAt']); + $this->assertNotFalse(\strtotime($response['body']['keys'][0]['$updatedAt'])); $authsKeys = [ 'authEmailPassword', From a1e51d27eb0b1c4f6ed96c6968d6927e0cf0ed49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 11:57:14 +0200 Subject: [PATCH 062/115] PR review fixes --- phpunit.xml | 2 +- .../Projects/ProjectsConsoleClientTest.php | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index b566e232cd..9748c5a5c8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,7 +5,7 @@ bootstrap="app/init.php" colors="true" processIsolation="false" - stopOnFailure="true" + stopOnFailure="false" stopOnError="false" cacheDirectory=".phpunit.cache" > diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index 95859f8dea..8b0c1af57f 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -862,7 +862,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', ], $this->getHeaders()), [ 'name' => 'Custom key 1', - 'expire' => '2026-05-07 09:23:30.713', + 'expire' => '2099-05-07 09:23:30.713', ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -870,7 +870,7 @@ class ProjectsConsoleClientTest extends Scope 'content-type' => 'application/json', ], $this->getHeaders()), [ 'name' => 'Custom key 2', - 'expire' => '2026-05-07 11:23:30.713' + 'expire' => '2099-05-07 11:23:30.713' ]); $this->assertEquals(201, $response['headers']['status-code']); @@ -1091,8 +1091,8 @@ class ProjectsConsoleClientTest extends Scope $this->assertCount(2, $response['body']['devKeys']); $this->assertEquals('Custom key 1', $response['body']['devKeys'][0]['name']); $this->assertEquals('Custom key 2', $response['body']['devKeys'][1]['name']); - $this->assertEquals('2026-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']); - $this->assertEquals('2026-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']); + $this->assertEquals('2099-05-07T09:23:30.713+00:00', $response['body']['devKeys'][0]['expire']); + $this->assertEquals('2099-05-07T11:23:30.713+00:00', $response['body']['devKeys'][1]['expire']); foreach ($response['body']['devKeys'] as $devKey) { $this->assertIsString($devKey['$id']); @@ -1441,7 +1441,7 @@ class ProjectsConsoleClientTest extends Scope 'authPhone', ]; foreach ($authsKeys as $authsKey) { - $this->assertFalse($response['body'][$authsKey], 'Auth method should be enabled: ' . $authsKey); + $this->assertFalse($response['body'][$authsKey], 'Auth method should be disabled: ' . $authsKey); } $serviceKeys = [ @@ -1464,7 +1464,7 @@ class ProjectsConsoleClientTest extends Scope 'serviceStatusForMessaging', ]; foreach ($serviceKeys as $serviceKey) { - $this->assertFalse($response['body'][$serviceKey], 'Service should be enabled: ' . $serviceKey); + $this->assertFalse($response['body'][$serviceKey], 'Service should be disabled: ' . $serviceKey); } $protocolKeys = [ @@ -1473,7 +1473,7 @@ class ProjectsConsoleClientTest extends Scope 'protocolStatusForWebsocket', ]; foreach ($protocolKeys as $protocolKey) { - $this->assertFalse($response['body'][$protocolKey], 'Protocol should be enabled: ' . $protocolKey); + $this->assertFalse($response['body'][$protocolKey], 'Protocol should be disabled: ' . $protocolKey); } /** From 7e9d6f33c8377e19bd9f15efc5ed989b7e467f6e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 15:56:44 +0530 Subject: [PATCH 063/115] updated methods --- src/Appwrite/SDK/Specification/Format.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 30df5acf52..69bdf7f67a 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -743,6 +743,18 @@ abstract class Format break; case 'project': switch ($method) { + case 'updateAuthMethod': + switch ($param) { + case 'methodId': + return 'AuthMethodId'; + } + break; + case 'getPolicy': + switch ($param) { + case 'policyId': + return 'ProjectPolicyId'; + } + break; case 'getEmailTemplate': case 'updateEmailTemplate': switch ($param) { From 706459e3140d6dc27470bcfb0eba9a5ebe4f6418 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 16:01:52 +0530 Subject: [PATCH 064/115] updated --- src/Appwrite/SDK/Specification/Format.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 69bdf7f67a..8db06b726a 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -746,7 +746,7 @@ abstract class Format case 'updateAuthMethod': switch ($param) { case 'methodId': - return 'AuthMethodId'; + return 'AuthMethod'; } break; case 'getPolicy': From 7917506b5880d267fdf50bd618d030e442efb166 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 16:30:23 +0530 Subject: [PATCH 065/115] updated oauth --- src/Appwrite/SDK/Specification/Format.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 8db06b726a..32ef4723f1 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -755,6 +755,12 @@ abstract class Format return 'ProjectPolicyId'; } break; + case 'getOAuth2Provider': + switch ($param) { + case 'providerId': + return 'OAuthProvider'; + } + break; case 'getEmailTemplate': case 'updateEmailTemplate': switch ($param) { From 399f75e8e7a02d9b386a3af164c67e0c70d8d8ce Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 16:35:56 +0530 Subject: [PATCH 066/115] updated --- src/Appwrite/SDK/Specification/Format.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 32ef4723f1..777479f7e3 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -68,6 +68,17 @@ abstract class Format 'mock-unverified' ], ], + [ + 'namespace' => 'project', + 'methods' => [ + 'getOAuth2Provider' + ], + 'parameter' => 'providerId', + 'excludeKeys' => [ + 'mock', + 'mock-unverified' + ], + ], ]; /** From e4b51d0abbc4d1820b34e38bb067c60274c14885 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 13:10:05 +0100 Subject: [PATCH 067/115] Drop custom utopia-php/migration VCS repo (now resolved via Packagist) --- composer.json | 8 +------- composer.lock | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index e396f6ad10..4a60944351 100644 --- a/composer.json +++ b/composer.json @@ -118,11 +118,5 @@ "php-http/discovery": true, "tbachert/spi": true } - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/utopia-php/migration" - } - ] + } } diff --git a/composer.lock b/composer.lock index a1f8309c17..eb06192bb9 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": "d0e14ee465162e3b5e56d6cef9ddbb8d", + "content-hash": "acd8a12e57d36a970effa84f5c8ccf50", "packages": [ { "name": "adhocore/jwt", From 409cebb26e4f50fb041b54a5f0552dc4a9277a89 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 8 May 2026 00:12:20 +1200 Subject: [PATCH 068/115] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Platform/Workers/Migrations.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index c893d022b8..bf64285299 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -556,7 +556,7 @@ class Migrations extends Action } elseif ($th instanceof MigrationException) { $publish = false; } else { - $publish = true; + $publish = $th->getCode() === 0 || $th->getCode() >= 500; } if ($publish) { From 7d0843cc7fa9c94c9c4332f028c7604eb694909e Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 7 May 2026 13:23:21 +0100 Subject: [PATCH 069/115] Migrate queueForBuilds to publisherForBuilds pattern Replaces the stateful Appwrite\Event\Build queue class with a stateless BuildPublisher and BuildMessage DTO, matching the publisher pattern used by audits, certificates, executions, migrations, screenshots, stats, and usage. Call sites now enqueue messages directly instead of mutating a shared event object and relying on the API shutdown hook. Co-Authored-By: Claude Opus 4.7 --- app/controllers/shared/api.php | 13 +- app/init/resources.php | 5 + app/init/resources/request.php | 4 - app/init/worker/message.php | 5 - src/Appwrite/Event/Build.php | 146 ------------------ src/Appwrite/Event/Message/Build.php | 42 +++++ src/Appwrite/Event/Publisher/Build.php | 27 ++++ .../Platform/Modules/Compute/Base.php | 33 ++-- .../Functions/Http/Deployments/Create.php | 22 ++- .../Http/Deployments/Duplicate/Create.php | 24 ++- .../Http/Deployments/Template/Create.php | 27 ++-- .../Functions/Http/Deployments/Vcs/Create.php | 11 +- .../Functions/Http/Functions/Create.php | 23 +-- .../Functions/Http/Functions/Update.php | 12 +- .../Health/Http/Health/Queue/Builds/Get.php | 8 +- .../Health/Http/Health/Queue/Failed/Get.php | 8 +- .../Modules/Sites/Http/Deployments/Create.php | 18 ++- .../Http/Deployments/Duplicate/Create.php | 18 ++- .../Http/Deployments/Template/Create.php | 22 +-- .../Sites/Http/Deployments/Vcs/Create.php | 8 +- .../Modules/Sites/Http/Sites/Update.php | 12 +- .../Http/GitHub/Authorize/External/Update.php | 8 +- .../Modules/VCS/Http/GitHub/Deployment.php | 25 +-- .../Modules/VCS/Http/GitHub/Events/Create.php | 18 +-- 24 files changed, 251 insertions(+), 288 deletions(-) delete mode 100644 src/Appwrite/Event/Build.php create mode 100644 src/Appwrite/Event/Message/Build.php create mode 100644 src/Appwrite/Event/Publisher/Build.php diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index c9e4f8b47d..3ca45ea783 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -3,7 +3,6 @@ use Appwrite\Auth\Key; use Appwrite\Auth\MFA\Type\TOTP; use Appwrite\Bus\Events\RequestCompleted; -use Appwrite\Event\Build; use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -489,7 +488,6 @@ Http::init() ->inject('auditContext') ->inject('queueForDeletes') ->inject('queueForDatabase') - ->inject('queueForBuilds') ->inject('usage') ->inject('queueForFunctions') ->inject('queueForMails') @@ -503,7 +501,7 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { $response->setUser($user); $request->setUser($user); @@ -618,12 +616,10 @@ Http::init() $queueForDatabase->setProject($project); $queueForMessaging->setProject($project); $queueForFunctions->setProject($project); - $queueForBuilds->setProject($project); $queueForMails->setProject($project); /* Auto-set platforms */ $queueForFunctions->setPlatform($platform); - $queueForBuilds->setPlatform($platform); $queueForMails->setPlatform($platform); $useCache = $route->getLabel('cache', false); @@ -800,7 +796,6 @@ Http::shutdown() ->inject('publisherForUsage') ->inject('queueForDeletes') ->inject('queueForDatabase') - ->inject('queueForBuilds') ->inject('queueForMessaging') ->inject('queueForFunctions') ->inject('queueForWebhooks') @@ -812,7 +807,7 @@ Http::shutdown() ->inject('bus') ->inject('apiKey') ->inject('mode') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, AuditContext $auditContext, Audit $publisherForAudits, Context $usage, UsagePublisher $publisherForUsage, Delete $queueForDeletes, EventDatabase $queueForDatabase, Messaging $queueForMessaging, Func $queueForFunctions, Event $queueForWebhooks, Realtime $queueForRealtime, Database $dbForProject, Authorization $authorization, callable $timelimit, EventProcessor $eventProcessor, Bus $bus, ?Key $apiKey, string $mode) use ($parseLabel) { $responsePayload = $response->getPayload(); @@ -961,10 +956,6 @@ Http::shutdown() $queueForDatabase->trigger(); } - if (! empty($queueForBuilds->getType())) { - $queueForBuilds->trigger(); - } - if (! empty($queueForMessaging->getType())) { $queueForMessaging->trigger(); } diff --git a/app/init/resources.php b/app/init/resources.php index 96457294de..15f669b2d0 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -2,6 +2,7 @@ use Appwrite\Event\Event; use Appwrite\Event\Publisher\Audit as AuditPublisher; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate as CertificatePublisher; use Appwrite\Event\Publisher\Execution as ExecutionPublisher; use Appwrite\Event\Publisher\Migration as MigrationPublisher; @@ -112,6 +113,10 @@ $container->set('publisherForStatsResources', fn (Publisher $publisher) => new S $publisher, new Queue(System::getEnv('_APP_STATS_RESOURCES_QUEUE_NAME', Event::STATS_RESOURCES_QUEUE_NAME)) ), ['publisher']); +$container->set('publisherForBuilds', fn (Publisher $publisher) => new BuildPublisher( + $publisher, + new Queue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) +), ['publisher']); /** * Platform configuration diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 70d691370d..9fd282c4ed 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -4,7 +4,6 @@ use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; use Appwrite\Auth\Key; use Appwrite\Databases\TransactionState; -use Appwrite\Event\Build; use Appwrite\Event\Context\Audit as AuditContext; use Appwrite\Event\Database as EventDatabase; use Appwrite\Event\Delete; @@ -123,9 +122,6 @@ return function (Container $container): void { $container->set('queueForMails', function (Publisher $publisher) { return new Mail($publisher); }, ['publisher']); - $container->set('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); - }, ['publisher']); $container->set('queueForDatabase', function (Publisher $publisher) { return new EventDatabase($publisher); }, ['publisher']); diff --git a/app/init/worker/message.php b/app/init/worker/message.php index 17796fadcd..1469934ad4 100644 --- a/app/init/worker/message.php +++ b/app/init/worker/message.php @@ -1,6 +1,5 @@ set('queueForBuilds', function (Publisher $publisher) { - return new Build($publisher); - }, ['publisher']); - $container->set('queueForDeletes', function (Publisher $publisher) { return new Delete($publisher); }, ['publisher']); diff --git a/src/Appwrite/Event/Build.php b/src/Appwrite/Event/Build.php deleted file mode 100644 index 4eaf108f15..0000000000 --- a/src/Appwrite/Event/Build.php +++ /dev/null @@ -1,146 +0,0 @@ -setQueue(System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME)) - ->setClass(System::getEnv('_APP_BUILDS_CLASS_NAME', Event::BUILDS_CLASS_NAME)); - } - - /** - * Sets template for the build event. - * - * @param Document $template - * @return self - */ - public function setTemplate(Document $template): self - { - $this->template = $template; - - return $this; - } - - /** - * Sets resource document for the build event. - * - * @param Document $resource - * @return self - */ - public function setResource(Document $resource): self - { - $this->resource = $resource; - - return $this; - } - - /** - * Returns set resource document for the build event. - * - * @return null|Document - */ - public function getResource(): ?Document - { - return $this->resource; - } - - /** - * Sets deployment for the build event. - * - * @param Document $deployment - * @return self - */ - public function setDeployment(Document $deployment): self - { - $this->deployment = $deployment; - - return $this; - } - - /** - * Returns set deployment for the build event. - * - * @return null|Document - */ - public function getDeployment(): ?Document - { - return $this->deployment; - } - - /** - * Sets type for the build event. - * - * @param string $type Can be `BUILD_TYPE_DEPLOYMENT` or `BUILD_TYPE_RETRY`. - * @return self - */ - public function setType(string $type): self - { - $this->type = $type; - - return $this; - } - - /** - * Returns set type for the function event. - * - * @return string - */ - public function getType(): string - { - return $this->type; - } - - /** - * Prepare payload for queue. - * - * @return array - */ - protected function preparePayload(): array - { - $platform = $this->platform; - if (empty($platform)) { - $platform = Config::getParam('platform', []); - } - - return [ - 'project' => $this->project, - 'resource' => $this->resource, - 'deployment' => $this->deployment, - 'type' => $this->type, - 'template' => $this->template, - 'platform' => $platform, - ]; - } - - /** - * Resets event. - * - * @return self - */ - public function reset(): self - { - $this->type = ''; - $this->resource = null; - $this->deployment = null; - $this->template = null; - $this->platform = []; - parent::reset(); - - return $this; - } -} diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php new file mode 100644 index 0000000000..7dfc8ea39a --- /dev/null +++ b/src/Appwrite/Event/Message/Build.php @@ -0,0 +1,42 @@ + $this->project->getArrayCopy(), + 'resource' => $this->resource->getArrayCopy(), + 'deployment' => $this->deployment->getArrayCopy(), + 'type' => $this->type, + 'template' => $this->template?->getArrayCopy() ?? [], + 'platform' => $this->platform, + ]; + } + + public static function fromArray(array $data): static + { + return new self( + project: new Document($data['project'] ?? []), + resource: new Document($data['resource'] ?? []), + deployment: new Document($data['deployment'] ?? []), + type: $data['type'] ?? '', + template: isset($data['template']) ? new Document($data['template']) : null, + platform: $data['platform'] ?? [], + ); + } +} diff --git a/src/Appwrite/Event/Publisher/Build.php b/src/Appwrite/Event/Publisher/Build.php new file mode 100644 index 0000000000..9b2a3b68a0 --- /dev/null +++ b/src/Appwrite/Event/Publisher/Build.php @@ -0,0 +1,27 @@ +publish($queue ?? $this->queue, $message); + } + + public function getSize(bool $failed = false, ?Queue $queue = null): int + { + return $this->getQueueSize($queue ?? $this->queue, $failed); + } +} diff --git a/src/Appwrite/Platform/Modules/Compute/Base.php b/src/Appwrite/Platform/Modules/Compute/Base.php index 85dfec3cfd..b0efac3829 100644 --- a/src/Appwrite/Platform/Modules/Compute/Base.php +++ b/src/Appwrite/Platform/Modules/Compute/Base.php @@ -2,7 +2,8 @@ namespace Appwrite\Platform\Modules\Compute; -use Appwrite\Event\Build; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Platform\Action; @@ -57,7 +58,7 @@ class Base extends Action return $allowedSpecifications[0] ?? APP_COMPUTE_SPECIFICATION_DEFAULT; } - public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsFunction(Request $request, Document $function, Document $project, Document $installation, Database $dbForProject, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, array $platform = [], string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); $entrypoint = $function->getAttribute('entrypoint', ''); @@ -150,16 +151,19 @@ class Base extends Action 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); return $deployment; } - public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, Build $queueForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document + public function redeployVcsSite(Request $request, Document $site, Document $project, Document $installation, Database $dbForProject, Database $dbForPlatform, BuildPublisher $publisherForBuilds, Document $template, GitHub $github, bool $activate, Authorization $authorization, array $platform, string $referenceType = 'branch', string $reference = ''): Document { $deploymentId = ID::unique(); $providerInstallationId = $installation->getAttribute('providerInstallationId', ''); @@ -358,11 +362,14 @@ class Base extends Action $this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); return $deployment; } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php index 757edc0484..57c465faef 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -87,9 +88,10 @@ class Create extends Action ->inject('project') ->inject('deviceForFunctions') ->inject('deviceForLocal') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('plan') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -106,9 +108,10 @@ class Create extends Action Document $project, Device $deviceForFunctions, Device $deviceForLocal, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $plan, - Authorization $authorization + Authorization $authorization, + array $platform ) { $activate = \strval($activate) === 'true' || \strval($activate) === '1'; @@ -272,10 +275,13 @@ class Create extends Action } // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); } else { if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php index 9884b12dba..76070c8bf5 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Duplicate/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Duplicate; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -61,8 +62,10 @@ class Create extends Action ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('deviceForFunctions') + ->inject('project') + ->inject('platform') ->callback($this->action(...)); } @@ -73,8 +76,10 @@ class Create extends Action Response $response, Database $dbForProject, Event $queueForEvents, - Build $queueForBuilds, - Device $deviceForFunctions + BuildPublisher $publisherForBuilds, + Device $deviceForFunctions, + Document $project, + array $platform ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -127,10 +132,13 @@ class Create extends Action 'latestDeploymentStatus' => $function->getAttribute('latestDeploymentStatus'), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); $queueForEvents ->setParam('functionId', $function->getId()) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php index 53af82e701..f18543c60e 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Template/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Template; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -76,9 +77,10 @@ class Create extends Base ->inject('dbForPlatform') ->inject('queueForEvents') ->inject('project') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -96,9 +98,10 @@ class Create extends Base Database $dbForPlatform, Event $queueForEvents, Document $project, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, - Authorization $authorization + Authorization $authorization, + array $platform ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -127,10 +130,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, + platform: $platform, referenceType: $type, reference: $reference ); @@ -184,11 +188,14 @@ class Create extends Base $this->updateEmptyManualRule($project, $function, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); $queueForEvents ->setParam('functionId', $function->getId()) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php index 587c09beba..a74fc12593 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Deployments/Vcs/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Deployments\Vcs; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -70,8 +70,9 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') + ->inject('platform') ->callback($this->action(...)); } @@ -86,8 +87,9 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, + array $platform, ) { $function = $dbForProject->getDocument('functions', $functionId); @@ -105,10 +107,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, + platform: $platform, reference: $reference, referenceType: $type ); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 7b294f3f90..00a91141fb 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -2,9 +2,10 @@ namespace Appwrite\Platform\Modules\Functions\Http\Functions; -use Appwrite\Event\Build; use Appwrite\Event\Event; use Appwrite\Event\Func; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Realtime; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Event\Webhook; @@ -115,7 +116,7 @@ class Create extends Base ->inject('timelimit') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('queueForRealtime') ->inject('queueForWebhooks') ->inject('queueForFunctions') @@ -157,7 +158,7 @@ class Create extends Base callable $timelimit, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Realtime $queueForRealtime, Webhook $queueForWebhooks, Func $queueForFunctions, @@ -326,10 +327,11 @@ class Create extends Base project: $project, installation: $installation, dbForProject: $dbForProject, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: true, + platform: $platform, reference: $providerBranch, referenceType: 'branch' ); @@ -367,11 +369,14 @@ class Create extends Base 'latestDeploymentStatus' => $deployment->getAttribute('status', ''), ])); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($function) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $function, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); } $functionsDomain = $platform['functionsDomain']; diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index 7d6572d336..b3fcb2c021 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Functions\Http\Functions; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Validator\FunctionEvent; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; @@ -105,11 +105,12 @@ class Update extends Base ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('dbForPlatform') ->inject('gitHub') ->inject('executor') ->inject('authorization') + ->inject('platform') ->callback($this->action(...)); } @@ -139,11 +140,12 @@ class Update extends Base Database $dbForProject, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Database $dbForPlatform, GitHub $github, Executor $executor, - Authorization $authorization + Authorization $authorization, + array $platform ) { // TODO: If only branch changes, re-deploy $function = $dbForProject->getDocument('functions', $functionId); @@ -281,7 +283,7 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $this->redeployVcsFunction($request, $function, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform); } // Inform scheduler if function is still active diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php index 8ae7c8687a..98e65e37e5 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Builds/Get.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Builds; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -42,16 +42,16 @@ class Get extends Base contentType: ContentType::JSON )) ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('response') ->callback($this->action(...)); } - public function action(int|string $threshold, Build $queueForBuilds, Response $response): void + public function action(int|string $threshold, BuildPublisher $publisherForBuilds, Response $response): void { $threshold = (int) $threshold; - $size = $queueForBuilds->getSize(); + $size = $publisherForBuilds->getSize(); $this->assertQueueThreshold($size, $threshold); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index 7602de45d3..0d0a787b46 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -2,7 +2,6 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; -use Appwrite\Event\Build; use Appwrite\Event\Database; use Appwrite\Event\Delete; use Appwrite\Event\Event; @@ -10,6 +9,7 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\Publisher\Audit; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate; use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot; @@ -83,7 +83,7 @@ class Get extends Base ->inject('publisherForUsage') ->inject('queueForWebhooks') ->inject('publisherForCertificates') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('queueForMessaging') ->inject('publisherForMigrations') ->inject('publisherForScreenshots') @@ -103,7 +103,7 @@ class Get extends Base UsagePublisher $publisherForUsage, Webhook $queueForWebhooks, Certificate $publisherForCertificates, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Messaging $queueForMessaging, MigrationPublisher $publisherForMigrations, Screenshot $publisherForScreenshots, @@ -120,7 +120,7 @@ class Get extends Base System::getEnv('_APP_STATS_USAGE_QUEUE_NAME', Event::STATS_USAGE_QUEUE_NAME) => $publisherForUsage, System::getEnv('_APP_WEBHOOK_QUEUE_NAME', Event::WEBHOOK_QUEUE_NAME) => $queueForWebhooks, System::getEnv('_APP_CERTIFICATES_QUEUE_NAME', Event::CERTIFICATES_QUEUE_NAME) => $publisherForCertificates, - System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $queueForBuilds, + System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME) => $publisherForBuilds, System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $queueForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php index 71ea5ceb2f..63ed776709 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; @@ -85,7 +86,7 @@ class Create extends Action ->inject('queueForEvents') ->inject('deviceForSites') ->inject('deviceForLocal') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('plan') ->inject('authorization') ->inject('platform') @@ -107,7 +108,7 @@ class Create extends Action Event $queueForEvents, Device $deviceForSites, Device $deviceForLocal, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $plan, Authorization $authorization, array $platform, @@ -315,10 +316,13 @@ class Create extends Action } // Start the build - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); } else { if ($deployment->isEmpty()) { $deployment = $dbForProject->createDocument('deployments', new Document([ diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php index 546549604b..b3619c6017 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Duplicate/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Duplicate; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; @@ -63,7 +64,7 @@ class Create extends Action ->inject('dbForProject') ->inject('dbForPlatform') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('deviceForSites') ->inject('authorization') ->inject('platform') @@ -79,7 +80,7 @@ class Create extends Action Database $dbForProject, Database $dbForPlatform, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Device $deviceForSites, Authorization $authorization, array $platform @@ -177,10 +178,13 @@ class Create extends Action ])) ); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + )); $queueForEvents ->setParam('siteId', $site->getId()) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php index f648c57a83..29854d473b 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Template/Create.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Template; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -77,7 +78,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') ->inject('platform') @@ -98,7 +99,7 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, Authorization $authorization, array $platform @@ -130,7 +131,7 @@ class Create extends Base installation: $installation, dbForProject: $dbForProject, dbForPlatform: $dbForPlatform, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, @@ -223,11 +224,14 @@ class Create extends Base $this->updateEmptyManualRule($project, $site, $deployment, $dbForPlatform, $authorization); - $queueForBuilds - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($site) - ->setDeployment($deployment) - ->setTemplate($template); + $publisherForBuilds->enqueue(new BuildMessage( + project: $project, + resource: $site, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + template: $template, + platform: $platform, + )); $queueForEvents ->setParam('siteId', $site->getId()) diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php index 4351dd8dd9..d34b8c4055 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Deployments/Vcs/Create.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Sites\Http\Deployments\Vcs; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\SDK\AuthType; @@ -71,7 +71,7 @@ class Create extends Base ->inject('dbForPlatform') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('gitHub') ->inject('authorization') ->inject('platform') @@ -89,7 +89,7 @@ class Create extends Base Database $dbForPlatform, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, GitHub $github, Authorization $authorization, array $platform @@ -111,7 +111,7 @@ class Create extends Base installation: $installation, dbForProject: $dbForProject, dbForPlatform: $dbForPlatform, - queueForBuilds: $queueForBuilds, + publisherForBuilds: $publisherForBuilds, template: $template, github: $github, activate: $activate, diff --git a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php index 3c0d090b7b..2aee03265e 100644 --- a/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php +++ b/src/Appwrite/Platform/Modules/Sites/Http/Sites/Update.php @@ -2,8 +2,8 @@ namespace Appwrite\Platform\Modules\Sites\Http\Sites; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Modules\Compute\Base; use Appwrite\Platform\Modules\Compute\Validator\Specification; @@ -99,10 +99,11 @@ class Update extends Base ->inject('dbForProject') ->inject('project') ->inject('queueForEvents') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('dbForPlatform') ->inject('gitHub') ->inject('executor') + ->inject('platform') ->callback($this->action(...)); } @@ -133,10 +134,11 @@ class Update extends Base Database $dbForProject, Document $project, Event $queueForEvents, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, Database $dbForPlatform, GitHub $github, - Executor $executor + Executor $executor, + array $platform ) { if (!empty($adapter)) { $configFramework = Config::getParam('frameworks')[$framework] ?? []; @@ -279,7 +281,7 @@ class Update extends Base // Redeploy logic if (!$isConnected && !empty($providerRepositoryId)) { - $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $queueForBuilds, new Document(), $github, true); + $this->redeployVcsFunction($request, $site, $project, $installation, $dbForProject, $publisherForBuilds, new Document(), $github, true, $platform); } $queueForEvents->setParam('siteId', $site->getId()); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php index 8b320535e9..a40d7fc6b9 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Authorize/External/Update.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Authorize\External; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment; @@ -60,7 +60,7 @@ class Update extends Action ->inject('dbForPlatform') ->inject('authorization') ->inject('getProjectDB') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('platform') ->callback($this->action(...)); } @@ -75,7 +75,7 @@ class Update extends Action Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $platform ) { $installation = $dbForPlatform->getDocument('installations', $installationId); @@ -130,7 +130,7 @@ class Update extends Action $providerCommitAuthor = $commitDetails["commitAuthor"] ?? ''; $providerCommitAuthorUrl = $commitDetails["commitAuthorUrl"] ?? ''; - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, true, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); $response->noContent(); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 33d7e984fb..8bc090bb03 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -2,8 +2,9 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub; -use Appwrite\Event\Build; use Appwrite\Event\Event; +use Appwrite\Event\Message\Build as BuildMessage; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Filter\BranchDomain as BranchDomainFilter; use Appwrite\Vcs\Comment; @@ -43,7 +44,7 @@ trait Deployment bool $external, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -528,14 +529,16 @@ trait Deployment $queueName = $this->getBuildQueueName($project, $dbForPlatform, $authorization); - $queueForBuilds - ->setQueue($queueName) - ->setType(BUILD_TYPE_DEPLOYMENT) - ->setResource($resource) - ->setDeployment($deployment) - ->setProject($project); // set the project because it won't be set for git deployments - - $queueForBuilds->trigger(); // must trigger here so that we create a build for each function/site + $publisherForBuilds->enqueue( + new BuildMessage( + project: $project, + resource: $resource, + deployment: $deployment, + type: BUILD_TYPE_DEPLOYMENT, + platform: $platform, + ), + new \Utopia\Queue\Queue($queueName) + ); Span::add("{$logBase}.build.triggered", 'true'); //TODO: Add event? @@ -545,8 +548,6 @@ trait Deployment } } - $queueForBuilds->reset(); // prevent shutdown hook from triggering again - if (!empty($errors)) { throw new Exception(Exception::GENERAL_UNKNOWN, \implode("\n", $errors)); } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index e3dbcfa0e9..0b81504309 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -2,7 +2,7 @@ namespace Appwrite\Platform\Modules\VCS\Http\GitHub\Events; -use Appwrite\Event\Build; +use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Extend\Exception; use Appwrite\Platform\Action; use Appwrite\Platform\Modules\VCS\Http\GitHub\Deployment; @@ -41,7 +41,7 @@ class Create extends Action ->inject('dbForPlatform') ->inject('authorization') ->inject('getProjectDB') - ->inject('queueForBuilds') + ->inject('publisherForBuilds') ->inject('platform') ->callback($this->action(...)); } @@ -53,7 +53,7 @@ class Create extends Action Database $dbForPlatform, Authorization $authorization, callable $getProjectDB, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, array $platform ) { $this->preprocessEvent($request); @@ -78,8 +78,8 @@ class Create extends Action match ($event) { $github::EVENT_INSTALLATION => $this->handleInstallationEvent($parsedPayload, $dbForPlatform, $authorization), - $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), - $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform), + $github::EVENT_PUSH => $this->handlePushEvent($parsedPayload, $githubAppId, $privateKey, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform), + $github::EVENT_PULL_REQUEST => $this->handlePullRequestEvent($parsedPayload, $privateKey, $githubAppId, $github, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform), default => null, }; @@ -129,7 +129,7 @@ class Create extends Action GitHub $github, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -164,7 +164,7 @@ class Create extends Action // Create new deployment only on push (not committed by us) and not when branch is deleted if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) { - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } } @@ -175,7 +175,7 @@ class Create extends Action GitHub $github, Database $dbForPlatform, Authorization $authorization, - Build $queueForBuilds, + BuildPublisher $publisherForBuilds, callable $getProjectDB, array $platform, ) { @@ -216,7 +216,7 @@ class Create extends Action Query::orderDesc('$createdAt') ])); - $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); + $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthor, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, $providerPullRequestId, $external, $dbForPlatform, $authorization, $publisherForBuilds, $getProjectDB, $platform); } elseif ($action == "closed") { // Allowed external contributions cleanup From 98b4e9b0639ced493044e7716ea35bd7b6be3f59 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 13:30:21 +0100 Subject: [PATCH 070/115] Loosen utopia-php/migration constraint back to 1.* (matches 1.9.x baseline) --- composer.json | 2 +- composer.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4a60944351..9a84be6111 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.22.*", - "utopia-php/migration": "1.10.*", + "utopia-php/migration": "1.*", "utopia-php/platform": "0.13.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", diff --git a/composer.lock b/composer.lock index eb06192bb9..d356362788 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": "acd8a12e57d36a970effa84f5c8ccf50", + "content-hash": "ec2ad489c60f0102f0dfab223b6d1fe4", "packages": [ { "name": "adhocore/jwt", From 81e57ea30e2a0b2884185a4716ac106034ad3fee Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 13:36:49 +0100 Subject: [PATCH 071/115] Revert gate fallback to always-publish on unknown exception types --- src/Appwrite/Platform/Workers/Migrations.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index bf64285299..f77a1cede0 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -550,13 +550,13 @@ class Migrations extends Action // Mirror general.php's HTTP-error pattern: typed AppwriteException uses its // registry-driven isPublishable() flag; library-thrown Migration\Exception is - // always user-facing; anything else falls back to the code heuristic. + // always user-facing; anything else is unknown and surfaced to Sentry. if ($th instanceof Exception) { $publish = $th->isPublishable(); } elseif ($th instanceof MigrationException) { $publish = false; } else { - $publish = $th->getCode() === 0 || $th->getCode() >= 500; + $publish = true; } if ($publish) { From 3676af925c19ace8ae9ddb36d456ac05d6ea33b7 Mon Sep 17 00:00:00 2001 From: Prem Palanisamy Date: Thu, 7 May 2026 13:45:57 +0100 Subject: [PATCH 072/115] Sanitize bubbled message for non-typed exceptions on migration document --- src/Appwrite/Platform/Workers/Migrations.php | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index f77a1cede0..b6c295b3bb 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -572,15 +572,30 @@ class Migrations extends Action $destinationErrors = $destination?->getErrors() ?? []; if ($caughtError !== null) { - $bubbled = $caughtError instanceof MigrationException - ? $caughtError - : new MigrationException( + if ($caughtError instanceof MigrationException) { + // library-thrown, message constructed by us + $bubbled = $caughtError; + } elseif ($caughtError instanceof Exception) { + // typed AppwriteException — message comes from the curated registry + $bubbled = new MigrationException( resourceName: '', resourceGroup: '', message: $caughtError->getMessage(), code: $caughtError->getCode(), previous: $caughtError, ); + } else { + // unknown throwable — raw message may embed internal hostnames, + // DSNs, tokens, etc. Replace with a generic user-facing string; + // the original is preserved on `previous:` for Sentry. + $bubbled = new MigrationException( + resourceName: '', + resourceGroup: '', + message: 'Migration failed due to an unexpected error.', + code: $caughtError->getCode() ?: 500, + previous: $caughtError, + ); + } $destinationErrors[] = $bubbled; } From f947fde7013eb25f94e4cc8dec63a867816dcc7a Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Thu, 7 May 2026 13:56:46 +0100 Subject: [PATCH 073/115] Restore platform fallback and null-template round-trip in BuildMessage - toArray() falls back to Config::getParam('platform') when the in-memory platform is empty, matching the old Build event behavior so workers always receive a populated platform array. - toArray() emits null for an absent template instead of an empty array, and fromArray() treats empty/null/missing template as null so the round-trip preserves the no-template case. Co-Authored-By: Claude Opus 4.7 --- src/Appwrite/Event/Message/Build.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Event/Message/Build.php b/src/Appwrite/Event/Message/Build.php index 7dfc8ea39a..0c8967aff6 100644 --- a/src/Appwrite/Event/Message/Build.php +++ b/src/Appwrite/Event/Message/Build.php @@ -2,6 +2,7 @@ namespace Appwrite\Event\Message; +use Utopia\Config\Config; use Utopia\Database\Document; final class Build extends Base @@ -18,13 +19,15 @@ final class Build extends Base public function toArray(): array { + $platform = !empty($this->platform) ? $this->platform : Config::getParam('platform', []); + return [ 'project' => $this->project->getArrayCopy(), 'resource' => $this->resource->getArrayCopy(), 'deployment' => $this->deployment->getArrayCopy(), 'type' => $this->type, - 'template' => $this->template?->getArrayCopy() ?? [], - 'platform' => $this->platform, + 'template' => $this->template?->getArrayCopy(), + 'platform' => $platform, ]; } @@ -35,7 +38,7 @@ final class Build extends Base resource: new Document($data['resource'] ?? []), deployment: new Document($data['deployment'] ?? []), type: $data['type'] ?? '', - template: isset($data['template']) ? new Document($data['template']) : null, + template: !empty($data['template']) ? new Document($data['template']) : null, platform: $data['platform'] ?? [], ); } From 708aea25324eaa585c08871609a5904c22f725df Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 8 May 2026 01:07:12 +1200 Subject: [PATCH 074/115] chore: pin github actions to sha and bump to latest Pin every third-party action in .github/workflows/ to a full commit SHA with a trailing version comment, and bump to the latest stable release. Defends against tag-rewrite supply-chain attacks while keeping versions legible. --- .github/workflows/ai-moderator.yml | 2 +- .github/workflows/auto-label-issue.yml | 2 +- .github/workflows/ci.yml | 104 ++++++++++++------------- .github/workflows/cleanup-cache.yml | 2 +- .github/workflows/codeql-analysis.yml | 8 +- .github/workflows/nightly.yml | 12 +-- .github/workflows/publish.yml | 12 +-- .github/workflows/release.yml | 12 +-- .github/workflows/sdk-preview.yml | 4 +- .github/workflows/specs.yml | 2 +- .github/workflows/stale.yml | 2 +- 11 files changed, 81 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ai-moderator.yml b/.github/workflows/ai-moderator.yml index 483f3dbeee..948fa6c0c1 100644 --- a/.github/workflows/ai-moderator.yml +++ b/.github/workflows/ai-moderator.yml @@ -25,6 +25,6 @@ jobs: runs-on: ubuntu-latest steps: - name: AI Moderator - uses: github/ai-moderator@v1 + uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1.1.4 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/auto-label-issue.yml b/.github/workflows/auto-label-issue.yml index e0eb0de98d..0151c2f9c1 100644 --- a/.github/workflows/auto-label-issue.yml +++ b/.github/workflows/auto-label-issue.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Issue Labeler - uses: github/issue-labeler@v3.4 + uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 # v3.4 with: configuration-path: .github/labeler.yml enable-versioned-regex: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3b3e113..29cc1d129e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: actions: read security-events: write contents: read - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@v2.3.3" + uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5 security: name: Checks / Image @@ -43,13 +43,13 @@ jobs: security-events: write steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 submodules: 'recursive' - name: Build the Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: false @@ -58,7 +58,7 @@ jobs: target: production - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: 'pr_image:${{ github.sha }}' format: 'sarif' @@ -66,7 +66,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Run Trivy vulnerability scanner on source code - uses: aquasecurity/trivy-action@0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' scan-ref: '.' @@ -76,14 +76,14 @@ jobs: skip-setup-trivy: true - name: Upload image scan results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' category: 'trivy-image' - name: Upload source code scan results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' @@ -94,10 +94,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -119,7 +119,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 @@ -127,7 +127,7 @@ jobs: if: github.event_name == 'pull_request' - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -144,10 +144,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' tools: composer:v2 @@ -157,7 +157,7 @@ jobs: run: composer install --prefer-dist --no-progress --ignore-platform-reqs - name: Cache PHPStan result cache - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .phpstan-cache key: phpstan-${{ github.sha }} @@ -172,10 +172,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: '8.3' extensions: swoole @@ -193,10 +193,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repo - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' @@ -212,7 +212,7 @@ jobs: steps: - name: Generate matrix id: generate - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const allDatabases = ['MariaDB', 'PostgreSQL', 'MongoDB']; @@ -253,28 +253,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push Appwrite - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true @@ -297,16 +297,16 @@ jobs: packages: read steps: - name: checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -327,7 +327,7 @@ jobs: run: docker compose exec -T appwrite vars - name: Run Unit Tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -350,16 +350,16 @@ jobs: packages: read steps: - name: checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -385,7 +385,7 @@ jobs: done - name: Run General Tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -464,7 +464,7 @@ jobs: paratest_processes: 1 steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set environment run: | @@ -488,13 +488,13 @@ jobs: fi - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -525,7 +525,7 @@ jobs: done - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -573,18 +573,18 @@ jobs: mode: ${{ fromJSON(needs.matrix.outputs.modes) }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -607,7 +607,7 @@ jobs: docker compose up -d --quiet-pull --wait - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -640,16 +640,16 @@ jobs: mode: ${{ fromJSON(needs.matrix.outputs.modes) }} steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -679,7 +679,7 @@ jobs: done - name: Run tests - uses: itznotabug/php-retry@v3 + uses: itznotabug/php-retry@d6bef45a8bff490babfb613e33b00d133e4062f0 # v3 with: max_attempts: 2 retry_wait_seconds: 60 @@ -711,18 +711,18 @@ jobs: packages: read steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -735,7 +735,7 @@ jobs: docker tag ${{ env.REGISTRY_IMAGE }}:${{ github.sha }} ${{ env.IMAGE }}:after - name: Setup k6 - uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 + uses: grafana/setup-k6-action@db07bd9765aac508ef18982e52ab937fe633a065 # v1.2.1 with: k6-version: ${{ env.K6_VERSION }} @@ -774,7 +774,7 @@ jobs: - name: Benchmark before if: steps.benchmark_before_start.outcome == 'success' continue-on-error: true - uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0 env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '5' @@ -826,7 +826,7 @@ jobs: - name: Benchmark after id: benchmark_after continue-on-error: true - uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d + uses: grafana/run-k6-action@de51a7390bdf0ac85a3bef493691bd71d4c7c158 # v1.4.0 env: APPWRITE_ENDPOINT: 'http://localhost/v1' APPWRITE_BENCHMARK_ITERATIONS: '5' @@ -846,7 +846,7 @@ jobs: - name: Comment on PR if: always() - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: BENCHMARK_BASE_REF: ${{ github.event.pull_request.base.ref }} BENCHMARK_HEAD_REF: ${{ github.event.pull_request.head.ref }} @@ -856,7 +856,7 @@ jobs: await comment({ github, context, core }); - name: Save results - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ !cancelled() }} with: name: benchmark-results diff --git a/.github/workflows/cleanup-cache.yml b/.github/workflows/cleanup-cache.yml index 4b6b13d35d..e4f28816be 100644 --- a/.github/workflows/cleanup-cache.yml +++ b/.github/workflows/cleanup-cache.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Cleanup run: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7edfde0aae..cb9b09b496 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -47,14 +47,14 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: ${{ matrix.language }} # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c4289678bb..0a49f658ac 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -10,13 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive - name: Build the Docker image run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: 'appwrite_image:latest' format: 'sarif' @@ -24,7 +24,7 @@ jobs: ignore-unfixed: 'false' severity: 'CRITICAL,HIGH' - name: Upload Docker Image Scan Results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-image-results.sarif') != '' with: sarif_file: 'trivy-image-results.sarif' @@ -35,16 +35,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Trivy vulnerability scanner on filesystem - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' format: 'sarif' output: 'trivy-fs-results.sarif' severity: 'CRITICAL,HIGH' - name: Upload Code Scan Results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 if: always() && hashFiles('trivy-fs-results.sarif') != '' with: sarif_file: 'trivy-fs-results.sarif' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 692861d44d..68ab657213 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,33 +12,33 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 submodules: recursive - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: appwrite/cloud tags: | type=ref,event=tag - name: Build & Publish to DockerHub - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84fc4c9fba..ed4e46d811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. @@ -20,20 +20,20 @@ jobs: submodules: recursive - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: appwrite/appwrite tags: | @@ -42,7 +42,7 @@ jobs: type=semver,pattern={{major}} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/sdk-preview.yml b/.github/workflows/sdk-preview.yml index f81346a7d1..dacc37a64a 100644 --- a/.github/workflows/sdk-preview.yml +++ b/.github/workflows/sdk-preview.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set SDK type id: set-sdk @@ -49,7 +49,7 @@ jobs: docker compose exec appwrite sdks --platform=${{ steps.set-sdk.outputs.platform }} --sdk=${{ steps.set-sdk.outputs.sdk_type }} --version=latest --git=no sudo chown -R $USER:$USER ./app/sdks/${{ steps.set-sdk.outputs.platform }}-${{ steps.set-sdk.outputs.sdk_type }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index 6f377354d5..85c76bacd3 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: recursive diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6e4a8ba73b..73b767aafe 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/stale@v10 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-message: "This issue has been labeled as a 'question', indicating that it requires additional information from the requestor. It has been inactive for 7 days. If no further activity occurs, this issue will be closed in 14 days." From 21ba13a076a102a2bd2061b0099d32e0438e1f4d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 7 May 2026 18:40:26 +0530 Subject: [PATCH 075/115] updated --- src/Appwrite/SDK/Specification/Format.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index 777479f7e3..4c7fa6f377 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -763,7 +763,7 @@ abstract class Format case 'getPolicy': switch ($param) { case 'policyId': - return 'ProjectPolicyId'; + return 'ProjectPolicy'; } break; case 'getOAuth2Provider': From b2080fb8f9465288b79495dcd66696001b744937 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Thu, 7 May 2026 17:07:13 +0200 Subject: [PATCH 076/115] refactor: migrate to different ci runners --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc3b3e113..5b80746db1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,19 +445,19 @@ jobs: ] include: - service: Databases - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 paratest_processes: 3 timeout_minutes: 30 - service: Sites - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 - service: Functions - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 - service: Avatars - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 - service: Realtime - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 - service: TablesDB - runner: blacksmith-4vcpu-ubuntu-2404 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 paratest_processes: 3 timeout_minutes: 30 - service: Migrations From c3881f9974d391931b71ba7526f2ba82fdafcd1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 7 May 2026 17:13:26 +0200 Subject: [PATCH 077/115] Fix console email policy features --- app/controllers/api/account.php | 36 +++++++++---------- app/controllers/api/users.php | 12 +++---- .../Modules/Teams/Http/Memberships/Create.php | 6 ++-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 65fd0bfb83..ac07efc72b 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -332,15 +332,15 @@ Http::post('/v1/account') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -1676,15 +1676,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -1817,15 +1817,15 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { $failureRedirect(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { $failureRedirect(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { $failureRedirect(Exception::USER_EMAIL_FREE); } @@ -2175,15 +2175,15 @@ Http::post('/v1/account/tokens/magic-url') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -2496,15 +2496,15 @@ Http::post('/v1/account/tokens/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -3417,15 +3417,15 @@ Http::patch('/v1/account/email') throw new Exception(Exception::GENERAL_INVALID_EMAIL); } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && $emailMetadata['emailIsDisposable']) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && $emailMetadata['emailIsCanonical'] === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && $emailMetadata['emailIsFree']) { throw new Exception(Exception::USER_EMAIL_FREE); } diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index abcecac396..3f52069609 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -131,15 +131,15 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } @@ -1563,15 +1563,15 @@ Http::patch('/v1/users/:userId/email') } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index e174029031..51115b7861 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -189,15 +189,15 @@ class Create extends Action } catch (\Throwable) { } - if (($plan['supportsDisposableEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsDisposableEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['disposableEmails'] ?? false) && ($emailMetadata['emailIsDisposable'] ?? false)) { throw new Exception(Exception::USER_EMAIL_DISPOSABLE); } - if (($plan['supportsCanonicalEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { + if ((($project->getId() === 'console') || ($plan['supportsCanonicalEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['canonicalEmails'] ?? false) && ($emailMetadata['emailIsCanonical'] ?? true) === false) { throw new Exception(Exception::USER_EMAIL_NOT_CANONICAL); } - if (($plan['supportsFreeEmailValidation'] ?? false) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { + if ((($project->getId() === 'console') || ($plan['supportsFreeEmailValidation'] ?? false)) && ($project->getAttribute('auths', [])['freeEmails'] ?? false) && ($emailMetadata['emailIsFree'] ?? false)) { throw new Exception(Exception::USER_EMAIL_FREE); } From 20ab5b026e01913ebd03ae904f821730fcc75491 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 08:32:07 +0530 Subject: [PATCH 078/115] Add cached response cache-control callback --- app/controllers/shared/api.php | 22 +++++++++++++++++-- app/init/resources.php | 4 ++++ .../Http/Buckets/Files/Preview/Get.php | 20 +++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 3ca45ea783..3c8f1d4425 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -501,7 +501,8 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->inject('cacheControlForResponse') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForResponse) { $response->setUser($user); $request->setUser($user); @@ -639,6 +640,9 @@ Http::init() $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { + $bucket = new Document(); + $file = new Document(); + $fileSecurity = null; $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0]; @@ -702,8 +706,22 @@ Http::init() $cache->save($key, $data); } + $cacheControl = $cacheControlForResponse([ + 'route' => $route, + 'source' => 'cache', + 'project' => $project, + 'user' => $user, + 'bucket' => $bucket, + 'file' => $file, + 'fileSecurity' => $fileSecurity, + 'resourceToken' => $resourceToken, + 'cacheLog' => $cacheLog, + 'maxAge' => $timestamp, + 'isImageTransformation' => $isImageTransformation, + ]); + $response - ->addHeader('Cache-Control', sprintf('private, max-age=%d', $timestamp)) + ->addHeader('Cache-Control', $cacheControl ?? sprintf('private, max-age=%d', $timestamp)) ->addHeader('X-Appwrite-Cache', 'hit') ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); diff --git a/app/init/resources.php b/app/init/resources.php index 15f669b2d0..03fe9ce709 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -203,6 +203,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); +$container->set('cacheControlForResponse', fn () => function (array $context): ?string { + return null; +}); + $container->set('redis', function () { $host = System::getEnv('_APP_REDIS_HOST', 'localhost'); $port = System::getEnv('_APP_REDIS_PORT', 6379); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 4fa5006db8..c66c38c982 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -94,6 +94,7 @@ class Get extends Action ->inject('project') ->inject('authorization') ->inject('user') + ->inject('cacheControlForResponse') ->callback($this->action(...)); } @@ -120,7 +121,8 @@ class Get extends Action Device $deviceForLocal, Document $project, Authorization $authorization, - User $user + User $user, + callable $cacheControlForResponse ) { if (!\extension_loaded('imagick')) { @@ -294,8 +296,22 @@ class Get extends Action } } + $maxAge = 2592000; // 30 days + $cacheControl = $cacheControlForResponse([ + 'route' => $request->getRoute(), + 'source' => 'action', + 'project' => $project, + 'user' => $user, + 'bucket' => $bucket, + 'file' => $file, + 'fileSecurity' => $fileSecurity, + 'resourceToken' => $resourceToken, + 'maxAge' => $maxAge, + 'isImageTransformation' => true, + ]); + $response - ->addHeader('Cache-Control', 'private, max-age=2592000') // 30 days + ->addHeader('Cache-Control', $cacheControl ?? "private, max-age={$maxAge}") ->setContentType($contentType) ->file($data); From 7b5a5b8d197a387393f0e5b52274a097fc168002 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 08:44:35 +0530 Subject: [PATCH 079/115] Rename storage cache control hook --- app/controllers/shared/api.php | 8 ++++---- app/init/resources.php | 6 ++++-- .../Modules/Storage/Http/Buckets/Files/Preview/Get.php | 8 ++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 3c8f1d4425..edbf2a871d 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -501,8 +501,8 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->inject('cacheControlForResponse') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForResponse) { + ->inject('cacheControlForStorage') + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, AuditContext $auditContext, Delete $queueForDeletes, EventDatabase $queueForDatabase, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization, callable $cacheControlForStorage) { $response->setUser($user); $request->setUser($user); @@ -706,7 +706,7 @@ Http::init() $cache->save($key, $data); } - $cacheControl = $cacheControlForResponse([ + $cacheControl = $cacheControlForStorage([ 'route' => $route, 'source' => 'cache', 'project' => $project, @@ -721,7 +721,7 @@ Http::init() ]); $response - ->addHeader('Cache-Control', $cacheControl ?? sprintf('private, max-age=%d', $timestamp)) + ->addHeader('Cache-Control', $cacheControl) ->addHeader('X-Appwrite-Cache', 'hit') ->setContentType($cacheLog->getAttribute('mimeType')); $storageCacheOperationsCounter->add(1, ['result' => 'hit']); diff --git a/app/init/resources.php b/app/init/resources.php index 03fe9ce709..341d26dd4c 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -203,8 +203,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); -$container->set('cacheControlForResponse', fn () => function (array $context): ?string { - return null; +$container->set('cacheControlForStorage', fn () => function (array $context): string { + $maxAge = $context['maxAge'] ?? 0; + + return \sprintf('private, max-age=%d', \is_int($maxAge) ? $maxAge : 0); }); $container->set('redis', function () { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index c66c38c982..e8bb8c381b 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -94,7 +94,7 @@ class Get extends Action ->inject('project') ->inject('authorization') ->inject('user') - ->inject('cacheControlForResponse') + ->inject('cacheControlForStorage') ->callback($this->action(...)); } @@ -122,7 +122,7 @@ class Get extends Action Document $project, Authorization $authorization, User $user, - callable $cacheControlForResponse + callable $cacheControlForStorage ) { if (!\extension_loaded('imagick')) { @@ -297,7 +297,7 @@ class Get extends Action } $maxAge = 2592000; // 30 days - $cacheControl = $cacheControlForResponse([ + $cacheControl = $cacheControlForStorage([ 'route' => $request->getRoute(), 'source' => 'action', 'project' => $project, @@ -311,7 +311,7 @@ class Get extends Action ]); $response - ->addHeader('Cache-Control', $cacheControl ?? "private, max-age={$maxAge}") + ->addHeader('Cache-Control', $cacheControl) ->setContentType($contentType) ->file($data); From 1208aff569aebe390cff59f7b5ff3034e29a58e5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 08:52:24 +0530 Subject: [PATCH 080/115] Fix storage cache hook analyze --- .../Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index e8bb8c381b..7fa10bb636 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -298,7 +298,6 @@ class Get extends Action $maxAge = 2592000; // 30 days $cacheControl = $cacheControlForStorage([ - 'route' => $request->getRoute(), 'source' => 'action', 'project' => $project, 'user' => $user, From 6abd88b8f13cc99dcc806a477ff5e2c228379f32 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 09:44:00 +0530 Subject: [PATCH 081/115] Type storage cache control config --- app/controllers/shared/api.php | 27 +++++++++--------- app/init/resources.php | 7 ++--- .../Modules/Storage/Config/CacheControl.php | 28 +++++++++++++++++++ .../Http/Buckets/Files/Preview/Get.php | 23 +++++++-------- 4 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index edbf2a871d..4e4ccc4b52 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -19,6 +19,7 @@ use Appwrite\Event\Webhook; use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Functions\EventProcessor; +use Appwrite\Platform\Modules\Storage\Config\CacheControl; use Appwrite\SDK\Method; use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; @@ -706,19 +707,19 @@ Http::init() $cache->save($key, $data); } - $cacheControl = $cacheControlForStorage([ - 'route' => $route, - 'source' => 'cache', - 'project' => $project, - 'user' => $user, - 'bucket' => $bucket, - 'file' => $file, - 'fileSecurity' => $fileSecurity, - 'resourceToken' => $resourceToken, - 'cacheLog' => $cacheLog, - 'maxAge' => $timestamp, - 'isImageTransformation' => $isImageTransformation, - ]); + $cacheControl = $cacheControlForStorage(new CacheControl( + source: CacheControl::SOURCE_CACHE, + project: $project, + user: $user, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + maxAge: $timestamp, + isImageTransformation: $isImageTransformation, + fileSecurity: $fileSecurity, + cacheLog: $cacheLog, + route: $route, + )); $response ->addHeader('Cache-Control', $cacheControl) diff --git a/app/init/resources.php b/app/init/resources.php index 341d26dd4c..3142483868 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -9,6 +9,7 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; +use Appwrite\Platform\Modules\Storage\Config\CacheControl; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -203,10 +204,8 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); -$container->set('cacheControlForStorage', fn () => function (array $context): string { - $maxAge = $context['maxAge'] ?? 0; - - return \sprintf('private, max-age=%d', \is_int($maxAge) ? $maxAge : 0); +$container->set('cacheControlForStorage', fn () => function (CacheControl $config): string { + return \sprintf('private, max-age=%d', $config->maxAge); }); $container->set('redis', function () { diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php new file mode 100644 index 0000000000..643d765da5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php @@ -0,0 +1,28 @@ + 'action', - 'project' => $project, - 'user' => $user, - 'bucket' => $bucket, - 'file' => $file, - 'fileSecurity' => $fileSecurity, - 'resourceToken' => $resourceToken, - 'maxAge' => $maxAge, - 'isImageTransformation' => true, - ]); + $cacheControl = $cacheControlForStorage(new CacheControl( + source: CacheControl::SOURCE_ACTION, + project: $project, + user: $user, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + maxAge: $maxAge, + isImageTransformation: true, + fileSecurity: $fileSecurity, + )); $response ->addHeader('Cache-Control', $cacheControl) From 707e5d231a80af419aaee26ae5bad696dc993810 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 09:55:33 +0530 Subject: [PATCH 082/115] Scope storage cache control to previews --- app/controllers/shared/api.php | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 4e4ccc4b52..d404ae501a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -707,19 +707,22 @@ Http::init() $cache->save($key, $data); } - $cacheControl = $cacheControlForStorage(new CacheControl( - source: CacheControl::SOURCE_CACHE, - project: $project, - user: $user, - bucket: $bucket, - file: $file, - resourceToken: $resourceToken, - maxAge: $timestamp, - isImageTransformation: $isImageTransformation, - fileSecurity: $fileSecurity, - cacheLog: $cacheLog, - route: $route, - )); + $cacheControl = \sprintf('private, max-age=%d', $timestamp); + if ($isImageTransformation) { + $cacheControl = $cacheControlForStorage(new CacheControl( + source: CacheControl::SOURCE_CACHE, + project: $project, + user: $user, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + maxAge: $timestamp, + isImageTransformation: $isImageTransformation, + fileSecurity: $fileSecurity, + cacheLog: $cacheLog, + route: $route, + )); + } $response ->addHeader('Cache-Control', $cacheControl) From bf767369718cc79f48aea0fc26447d2795ab99e0 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 09:57:33 +0530 Subject: [PATCH 083/115] Split storage cache control context --- app/controllers/shared/api.php | 37 ++++++++++--------- app/init/resources.php | 4 +- .../Modules/Storage/Config/CacheControl.php | 10 +---- .../Storage/Config/StorageCacheControl.php | 31 ++++++++++++++++ .../Http/Buckets/Files/Preview/Get.php | 7 ++-- 5 files changed, 58 insertions(+), 31 deletions(-) create mode 100644 src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index d404ae501a..3ab2d165a3 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -20,6 +20,7 @@ use Appwrite\Extend\Exception; use Appwrite\Extend\Exception as AppwriteException; use Appwrite\Functions\EventProcessor; use Appwrite\Platform\Modules\Storage\Config\CacheControl; +use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\SDK\Method; use Appwrite\Usage\Context; use Appwrite\Utopia\Database\Documents\User; @@ -641,9 +642,7 @@ Http::init() $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { - $bucket = new Document(); - $file = new Document(); - $fileSecurity = null; + $storageCacheControl = null; $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0]; @@ -696,6 +695,22 @@ Http::init() ]))); } } + + if ($isImageTransformation) { + $storageCacheControl = new StorageCacheControl( + source: CacheControl::SOURCE_CACHE, + user: $user, + maxAge: $timestamp, + project: $project, + bucket: $bucket, + file: $file, + resourceToken: $resourceToken, + isImageTransformation: $isImageTransformation, + fileSecurity: $fileSecurity, + cacheLog: $cacheLog, + route: $route, + ); + } } $accessedAt = $cacheLog->getAttribute('accessedAt', ''); @@ -708,20 +723,8 @@ Http::init() } $cacheControl = \sprintf('private, max-age=%d', $timestamp); - if ($isImageTransformation) { - $cacheControl = $cacheControlForStorage(new CacheControl( - source: CacheControl::SOURCE_CACHE, - project: $project, - user: $user, - bucket: $bucket, - file: $file, - resourceToken: $resourceToken, - maxAge: $timestamp, - isImageTransformation: $isImageTransformation, - fileSecurity: $fileSecurity, - cacheLog: $cacheLog, - route: $route, - )); + if ($storageCacheControl !== null) { + $cacheControl = $cacheControlForStorage($storageCacheControl); } $response diff --git a/app/init/resources.php b/app/init/resources.php index 3142483868..c5d034a125 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -9,7 +9,7 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Platform\Modules\Storage\Config\CacheControl; +use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; @@ -204,7 +204,7 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { return $cache; }, ['pools', 'telemetry']); -$container->set('cacheControlForStorage', fn () => function (CacheControl $config): string { +$container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string { return \sprintf('private, max-age=%d', $config->maxAge); }); diff --git a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php index 643d765da5..ef2ace34ff 100644 --- a/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php +++ b/src/Appwrite/Platform/Modules/Storage/Config/CacheControl.php @@ -3,25 +3,17 @@ namespace Appwrite\Platform\Modules\Storage\Config; use Appwrite\Utopia\Database\Documents\User; -use Utopia\Database\Document; use Utopia\Http\Route; -final class CacheControl +class CacheControl { public const SOURCE_ACTION = 'action'; public const SOURCE_CACHE = 'cache'; public function __construct( public readonly string $source, - public readonly Document $project, public readonly User $user, - public readonly Document $bucket, - public readonly Document $file, - public readonly Document $resourceToken, public readonly int $maxAge, - public readonly bool $isImageTransformation, - public readonly ?bool $fileSecurity = null, - public readonly ?Document $cacheLog = null, public readonly ?Route $route = null, ) { } diff --git a/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php new file mode 100644 index 0000000000..ea4daddad8 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php @@ -0,0 +1,31 @@ + Date: Fri, 8 May 2026 09:59:36 +0530 Subject: [PATCH 084/115] Inline storage preview cache control --- app/controllers/shared/api.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 3ab2d165a3..0ef58862e7 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -642,7 +642,7 @@ Http::init() $data = $cache->load($key, $timestamp); if (! empty($data) && ! $cacheLog->isEmpty()) { - $storageCacheControl = null; + $cacheControl = \sprintf('private, max-age=%d', $timestamp); $parts = explode('/', $cacheLog->getAttribute('resourceType', '')); $type = $parts[0]; @@ -697,7 +697,7 @@ Http::init() } if ($isImageTransformation) { - $storageCacheControl = new StorageCacheControl( + $cacheControl = $cacheControlForStorage(new StorageCacheControl( source: CacheControl::SOURCE_CACHE, user: $user, maxAge: $timestamp, @@ -709,7 +709,7 @@ Http::init() fileSecurity: $fileSecurity, cacheLog: $cacheLog, route: $route, - ); + )); } } @@ -722,11 +722,6 @@ Http::init() $cache->save($key, $data); } - $cacheControl = \sprintf('private, max-age=%d', $timestamp); - if ($storageCacheControl !== null) { - $cacheControl = $cacheControlForStorage($storageCacheControl); - } - $response ->addHeader('Cache-Control', $cacheControl) ->addHeader('X-Appwrite-Cache', 'hit') From 48e2ee9acb5b606a2c0de76e94e9ff025454eb0d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 10:04:40 +0530 Subject: [PATCH 085/115] Test storage preview cache headers --- tests/e2e/Services/Storage/StorageBase.php | 58 ++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 29f7d70435..80fe5a9984 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -957,6 +957,64 @@ trait StorageBase $this->assertNotEquals($imageBefore->getImageBlob(), $imageAfter->getImageBlob()); } + public function testFilePreviewCacheControlOnCacheHit(): void + { + $data = $this->setupBucketFile(); + $bucketId = $data['bucketId']; + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $this->assertNotEmpty($file['body']['$id']); + + $fileId = $file['body']['$id']; + $params = [ + 'width' => 123, + 'height' => 45, + 'output' => 'png', + 'quality' => 80, + ]; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()); + + $preview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $headers, + $params + ); + + $this->assertEquals(200, $preview['headers']['status-code']); + $this->assertEquals('image/png', $preview['headers']['content-type']); + $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']); + $this->assertArrayNotHasKey('x-appwrite-cache', $preview['headers']); + $this->assertNotEmpty($preview['body']); + + $cachedPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $headers, + $params + ); + + $this->assertEquals(200, $cachedPreview['headers']['status-code']); + $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); + $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']); + $this->assertEquals('private, max-age=15552000', $cachedPreview['headers']['cache-control']); + $this->assertEquals($preview['body'], $cachedPreview['body']); + } + public function testFilePreviewZstdCompression(): void { $data = $this->setupZstdCompressionBucket(); From 96e51936013fcffb1204ce06e78db38a273bd7c9 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 10:18:34 +0530 Subject: [PATCH 086/115] Fix storage preview cache e2e assertion --- tests/e2e/Services/Storage/StorageBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 80fe5a9984..078a67f55d 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -998,7 +998,7 @@ trait StorageBase $this->assertEquals(200, $preview['headers']['status-code']); $this->assertEquals('image/png', $preview['headers']['content-type']); $this->assertEquals('private, max-age=2592000', $preview['headers']['cache-control']); - $this->assertArrayNotHasKey('x-appwrite-cache', $preview['headers']); + $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']); $this->assertNotEmpty($preview['body']); $cachedPreview = $this->client->call( From 75a30793ac134cc5a0ff305aad6108ff32c6780a Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 10:42:39 +0530 Subject: [PATCH 087/115] Remove preview flag from storage cache config --- app/controllers/shared/api.php | 1 - .../Platform/Modules/Storage/Config/StorageCacheControl.php | 1 - .../Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php | 1 - 3 files changed, 3 deletions(-) diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 0ef58862e7..14ffdc059f 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -705,7 +705,6 @@ Http::init() bucket: $bucket, file: $file, resourceToken: $resourceToken, - isImageTransformation: $isImageTransformation, fileSecurity: $fileSecurity, cacheLog: $cacheLog, route: $route, diff --git a/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php b/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php index ea4daddad8..2e076fa782 100644 --- a/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php +++ b/src/Appwrite/Platform/Modules/Storage/Config/StorageCacheControl.php @@ -16,7 +16,6 @@ final class StorageCacheControl extends CacheControl public readonly Document $bucket, public readonly Document $file, public readonly Document $resourceToken, - public readonly bool $isImageTransformation, public readonly bool $fileSecurity, public readonly ?Document $cacheLog = null, ?Route $route = null, diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 36f362a825..cb0d8c76ef 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -307,7 +307,6 @@ class Get extends Action bucket: $bucket, file: $file, resourceToken: $resourceToken, - isImageTransformation: true, fileSecurity: $fileSecurity, )); From 8e274012bd550c5641d94e026ae4660945bf5f04 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 10:48:12 +0530 Subject: [PATCH 088/115] Relax storage preview cache max-age assertion --- tests/e2e/Services/Storage/StorageBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 078a67f55d..9177108ca6 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1011,7 +1011,7 @@ trait StorageBase $this->assertEquals(200, $cachedPreview['headers']['status-code']); $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']); - $this->assertEquals('private, max-age=15552000', $cachedPreview['headers']['cache-control']); + $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']); $this->assertEquals($preview['body'], $cachedPreview['body']); } From 2207fff44d85fa7fb2a6b35a363470ec982e668b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 10:52:31 +0530 Subject: [PATCH 089/115] Wait for storage preview cache hit in e2e --- tests/e2e/Services/Storage/StorageBase.php | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 9177108ca6..5e09031a9c 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1001,16 +1001,20 @@ trait StorageBase $this->assertEquals('miss', $preview['headers']['x-appwrite-cache']); $this->assertNotEmpty($preview['body']); - $cachedPreview = $this->client->call( - Client::METHOD_GET, - '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', - $headers, - $params - ); + $cachedPreview = []; + $this->assertEventually(function () use (&$cachedPreview, $bucketId, $fileId, $headers, $params) { + $cachedPreview = $this->client->call( + Client::METHOD_GET, + '/storage/buckets/' . $bucketId . '/files/' . $fileId . '/preview', + $headers, + $params + ); + + $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']); + }); $this->assertEquals(200, $cachedPreview['headers']['status-code']); $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); - $this->assertEquals('hit', $cachedPreview['headers']['x-appwrite-cache']); $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']); $this->assertEquals($preview['body'], $cachedPreview['body']); } From 9337daf7ac6dc397bf4595cb16fc8b9d8aeeda42 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 11:15:17 +0530 Subject: [PATCH 090/115] Preserve default storage cache hit max age --- app/init/resources.php | 5 +++++ tests/e2e/Services/Storage/StorageBase.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/init/resources.php b/app/init/resources.php index c5d034a125..edd75e33d4 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -9,6 +9,7 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; +use Appwrite\Platform\Modules\Storage\Config\CacheControl; use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; @@ -205,6 +206,10 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { }, ['pools', 'telemetry']); $container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string { + if ($config->source === CacheControl::SOURCE_CACHE) { + return 'private, max-age=15552000'; + } + return \sprintf('private, max-age=%d', $config->maxAge); }); diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 5e09031a9c..56112101bf 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1015,7 +1015,7 @@ trait StorageBase $this->assertEquals(200, $cachedPreview['headers']['status-code']); $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); - $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']); + $this->assertEquals('private, max-age=15552000', $cachedPreview['headers']['cache-control']); $this->assertEquals($preview['body'], $cachedPreview['body']); } From 8ebf4fee46b28771aec991a67b108bf3340a3b8f Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 11:18:55 +0530 Subject: [PATCH 091/115] Use storage cache callback max age --- app/init/resources.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index edd75e33d4..c5d034a125 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -9,7 +9,6 @@ use Appwrite\Event\Publisher\Migration as MigrationPublisher; use Appwrite\Event\Publisher\Screenshot as ScreenshotPublisher; use Appwrite\Event\Publisher\StatsResources as StatsResourcesPublisher; use Appwrite\Event\Publisher\Usage as UsagePublisher; -use Appwrite\Platform\Modules\Storage\Config\CacheControl; use Appwrite\Platform\Modules\Storage\Config\StorageCacheControl; use Appwrite\Utopia\Database\Documents\User; use Executor\Executor; @@ -206,10 +205,6 @@ $container->set('cache', function (Group $pools, Telemetry $telemetry) { }, ['pools', 'telemetry']); $container->set('cacheControlForStorage', fn () => function (StorageCacheControl $config): string { - if ($config->source === CacheControl::SOURCE_CACHE) { - return 'private, max-age=15552000'; - } - return \sprintf('private, max-age=%d', $config->maxAge); }); From 3d35d140d123da68843b6895179aebf874a8d8ac Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 11:27:35 +0530 Subject: [PATCH 092/115] Relax storage preview cache hit max age --- tests/e2e/Services/Storage/StorageBase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Services/Storage/StorageBase.php b/tests/e2e/Services/Storage/StorageBase.php index 56112101bf..5e09031a9c 100644 --- a/tests/e2e/Services/Storage/StorageBase.php +++ b/tests/e2e/Services/Storage/StorageBase.php @@ -1015,7 +1015,7 @@ trait StorageBase $this->assertEquals(200, $cachedPreview['headers']['status-code']); $this->assertEquals('image/png', $cachedPreview['headers']['content-type']); - $this->assertEquals('private, max-age=15552000', $cachedPreview['headers']['cache-control']); + $this->assertStringStartsWith('private, max-age=', $cachedPreview['headers']['cache-control']); $this->assertEquals($preview['body'], $cachedPreview['body']); } From 6a0d2d53148444174e1d0912ab7d0ed34665a4bc Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 09:01:36 +0200 Subject: [PATCH 093/115] chore: add bigger disks to the provisioned setup --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b80746db1..6743c7f5e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,19 +445,19 @@ jobs: ] include: - service: Databases - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large paratest_processes: 3 timeout_minutes: 30 - service: Sites - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large - service: Functions - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large - service: Avatars - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large - service: Realtime - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large - service: TablesDB - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7 + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large paratest_processes: 3 timeout_minutes: 30 - service: Migrations From bd0e7456a79938b593e0d6e0c1e2e32ed88dc969 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 8 May 2026 09:08:42 +0200 Subject: [PATCH 094/115] refactor: update ci runners to use volume instead of disk --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6743c7f5e7..260a4656cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -445,19 +445,19 @@ jobs: ] include: - service: Databases - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g paratest_processes: 3 timeout_minutes: 30 - service: Sites - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Functions - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Avatars - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: Realtime - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g - service: TablesDB - runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/disk=large + runner: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/family=c7/volume=120g paratest_processes: 3 timeout_minutes: 30 - service: Migrations From 1a0a19a793972ca51ecf0f5e1b9f3b3566177dd6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 8 May 2026 13:25:35 +0530 Subject: [PATCH 095/115] Add search and pagination for repository branches --- .../vcs/list-repository-branches.md | 2 +- .../Repositories/Branches/XList.php | 73 ++++++++++++++++++- .../e2e/Services/VCS/VCSConsoleClientTest.php | 49 +++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/docs/references/vcs/list-repository-branches.md b/docs/references/vcs/list-repository-branches.md index eea1795a3e..b614c2ad13 100644 --- a/docs/references/vcs/list-repository-branches.md +++ b/docs/references/vcs/list-repository-branches.md @@ -1 +1 @@ -Get a list of all branches from a GitHub repository in your installation. This endpoint returns the names of all branches in the repository and their total count. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work. +Get a list of branches from a GitHub repository in your installation. This endpoint supports filtering by a search term and pagination using query strings such as `Query.limit()`, `Query.offset()`, `Query.cursorAfter()`, and `Query.cursorBefore()`. It returns branch names along with the total number of matches. The GitHub installation must be properly configured and have access to the requested repository for this endpoint to work. diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 8ead94b7cb..4d073e2b11 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -10,8 +10,11 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Query; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\ArrayList; use Utopia\Validator\Text; use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; @@ -49,6 +52,8 @@ class XList extends Action )) ->param('installationId', '', new Text(256), 'Installation Id') ->param('providerRepositoryId', '', new Text(256), 'Repository Id') + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true) ->inject('gitHub') ->inject('response') ->inject('dbForPlatform') @@ -58,10 +63,31 @@ class XList extends Action public function action( string $installationId, string $providerRepositoryId, + string $search, + array $queries, GitHub $github, Response $response, Database $dbForPlatform ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $allowedQueryMethods = [ + Query::TYPE_LIMIT, + Query::TYPE_OFFSET, + Query::TYPE_CURSOR_AFTER, + Query::TYPE_CURSOR_BEFORE, + ]; + + foreach ($queries as $query) { + if (!\in_array($query->getMethod(), $allowedQueryMethods, true)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Only limit, offset, cursorAfter, and cursorBefore queries are supported.'); + } + } + $installation = $dbForPlatform->getDocument('installations', $installationId); if ($installation->isEmpty()) { @@ -85,11 +111,56 @@ class XList extends Action $branches = $github->listBranches($owner, $repositoryName); + if (!empty($search)) { + $branches = \array_values(\array_filter($branches, fn (string $branch) => \stripos($branch, $search) !== false)); + } + + $total = \count($branches); + + $limitQuery = \current(\array_filter($queries, fn (Query $query) => $query->getMethod() === Query::TYPE_LIMIT)); + $offsetQuery = \current(\array_filter($queries, fn (Query $query) => $query->getMethod() === Query::TYPE_OFFSET)); + $cursorQuery = \current(\array_filter($queries, fn (Query $query) => \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true))); + + $limit = $limitQuery instanceof Query ? $limitQuery->getValue() : APP_LIMIT_LIST_DEFAULT; + $offset = $offsetQuery instanceof Query ? $offsetQuery->getValue() : 0; + + if (!\is_int($limit) || $limit < 0) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid limit query.'); + } + + if (!\is_int($offset) || $offset < 0) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid offset query.'); + } + + if ($cursorQuery instanceof Query) { + $cursor = $cursorQuery->getValue(); + if (!\is_string($cursor) || $cursor === '') { + throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid cursor query.'); + } + + $cursorIndex = \array_search($cursor, $branches, true); + if ($cursorIndex === false) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found."); + } + + $offset += $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER ? $cursorIndex + 1 : 0; + + if ($cursorQuery->getMethod() === Query::TYPE_CURSOR_BEFORE) { + $length = $limit === 0 ? 0 : $limit; + $start = \max(0, $cursorIndex - $length); + $branches = \array_slice($branches, $start, $length); + } else { + $branches = \array_slice($branches, $offset, $limit); + } + } else { + $branches = \array_slice($branches, $offset, $limit); + } + $response->dynamic(new Document([ 'branches' => \array_map(function ($branch) { return new Document(['name' => $branch]); }, $branches), - 'total' => \count($branches), + 'total' => $total, ]), Response::MODEL_BRANCH_LIST); } } diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php index 854e7110f1..7d01af6863 100644 --- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php +++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php @@ -513,6 +513,45 @@ class VCSConsoleClientTest extends Scope $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main'); $this->assertEquals($repositoryBranches['body']['branches'][1]['name'], 'test'); + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'search' => 'tes', + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 1); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::offset(1)->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'main']))->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + /** * Test for FAILURE */ @@ -522,6 +561,16 @@ class VCSConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(404, $repositoryBranches['headers']['status-code']); + + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::cursorAfter(new \Utopia\Database\Document(['$id' => 'missing-branch']))->toString(), + ], + ]); + + $this->assertEquals(400, $repositoryBranches['headers']['status-code']); } public function testCreateFunctionUsingVCS(): void From 3fbe77a27c7e3bd59000061697119f597816cd8f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 8 May 2026 13:31:32 +0530 Subject: [PATCH 096/115] Fix repository branch cursorBefore pagination --- .../Installations/Repositories/Branches/XList.php | 5 ++--- tests/e2e/Services/VCS/VCSConsoleClientTest.php | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 4d073e2b11..6cb7aae104 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -146,9 +146,8 @@ class XList extends Action $offset += $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER ? $cursorIndex + 1 : 0; if ($cursorQuery->getMethod() === Query::TYPE_CURSOR_BEFORE) { - $length = $limit === 0 ? 0 : $limit; - $start = \max(0, $cursorIndex - $length); - $branches = \array_slice($branches, $start, $length); + $start = \max(0, $cursorIndex - $limit); + $branches = \array_slice($branches, $start, $cursorIndex - $start); } else { $branches = \array_slice($branches, $offset, $limit); } diff --git a/tests/e2e/Services/VCS/VCSConsoleClientTest.php b/tests/e2e/Services/VCS/VCSConsoleClientTest.php index 7d01af6863..23007339de 100644 --- a/tests/e2e/Services/VCS/VCSConsoleClientTest.php +++ b/tests/e2e/Services/VCS/VCSConsoleClientTest.php @@ -552,6 +552,20 @@ class VCSConsoleClientTest extends Scope $this->assertCount(1, $repositoryBranches['body']['branches']); $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'test'); + $repositoryBranches = $this->client->call(Client::METHOD_GET, '/vcs/github/installations/' . $installationId . '/providerRepositories/' . $this->providerRepositoryId . '/branches', array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(1)->toString(), + Query::cursorBefore(new \Utopia\Database\Document(['$id' => 'test']))->toString(), + ], + ]); + + $this->assertEquals(200, $repositoryBranches['headers']['status-code']); + $this->assertEquals($repositoryBranches['body']['total'], 2); + $this->assertCount(1, $repositoryBranches['body']['branches']); + $this->assertEquals($repositoryBranches['body']['branches'][0]['name'], 'main'); + /** * Test for FAILURE */ From 370a82388854f397d7d389557a9bb80ef9879bca Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 8 May 2026 13:43:02 +0530 Subject: [PATCH 097/115] Use a query validator for repository branch pagination --- .../Repositories/Branches/XList.php | 46 ++++++------------- .../Database/Validator/Queries/Branches.php | 20 ++++++++ .../Database/Validator/Query/BranchCursor.php | 39 ++++++++++++++++ 3 files changed, 72 insertions(+), 33 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Validator/Queries/Branches.php create mode 100644 src/Appwrite/Utopia/Database/Validator/Query/BranchCursor.php diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index 6cb7aae104..fe5b8fad5c 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -7,6 +7,7 @@ use Appwrite\Platform\Action; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Validator\Queries\Branches; use Appwrite\Utopia\Response; use Utopia\Database\Database; use Utopia\Database\Document; @@ -14,7 +15,6 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; -use Utopia\Validator\ArrayList; use Utopia\Validator\Text; use Utopia\VCS\Adapter\Git\GitHub; use Utopia\VCS\Exception\RepositoryNotFound; @@ -53,7 +53,7 @@ class XList extends Action ->param('installationId', '', new Text(256), 'Installation Id') ->param('providerRepositoryId', '', new Text(256), 'Repository Id') ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) - ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true) + ->param('queries', [], new Branches(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit, offset, cursorAfter, and cursorBefore', true) ->inject('gitHub') ->inject('response') ->inject('dbForPlatform') @@ -75,19 +75,6 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $allowedQueryMethods = [ - Query::TYPE_LIMIT, - Query::TYPE_OFFSET, - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE, - ]; - - foreach ($queries as $query) { - if (!\in_array($query->getMethod(), $allowedQueryMethods, true)) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Only limit, offset, cursorAfter, and cursorBefore queries are supported.'); - } - } - $installation = $dbForPlatform->getDocument('installations', $installationId); if ($installation->isEmpty()) { @@ -116,24 +103,17 @@ class XList extends Action } $total = \count($branches); + [ + 'limit' => $limit, + 'offset' => $offset, + 'cursor' => $cursor, + 'cursorDirection' => $cursorDirection, + ] = Query::groupByType($queries); - $limitQuery = \current(\array_filter($queries, fn (Query $query) => $query->getMethod() === Query::TYPE_LIMIT)); - $offsetQuery = \current(\array_filter($queries, fn (Query $query) => $query->getMethod() === Query::TYPE_OFFSET)); - $cursorQuery = \current(\array_filter($queries, fn (Query $query) => \in_array($query->getMethod(), [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true))); + $limit ??= APP_LIMIT_LIST_DEFAULT; + $offset ??= 0; - $limit = $limitQuery instanceof Query ? $limitQuery->getValue() : APP_LIMIT_LIST_DEFAULT; - $offset = $offsetQuery instanceof Query ? $offsetQuery->getValue() : 0; - - if (!\is_int($limit) || $limit < 0) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid limit query.'); - } - - if (!\is_int($offset) || $offset < 0) { - throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid offset query.'); - } - - if ($cursorQuery instanceof Query) { - $cursor = $cursorQuery->getValue(); + if ($cursor !== null) { if (!\is_string($cursor) || $cursor === '') { throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid cursor query.'); } @@ -143,9 +123,9 @@ class XList extends Action throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Branch '{$cursor}' for the 'cursor' value not found."); } - $offset += $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER ? $cursorIndex + 1 : 0; + $offset += $cursorDirection === Database::CURSOR_AFTER ? $cursorIndex + 1 : 0; - if ($cursorQuery->getMethod() === Query::TYPE_CURSOR_BEFORE) { + if ($cursorDirection === Database::CURSOR_BEFORE) { $start = \max(0, $cursorIndex - $limit); $branches = \array_slice($branches, $start, $cursorIndex - $start); } else { diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php new file mode 100644 index 0000000000..82ca911747 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Branches.php @@ -0,0 +1,20 @@ +getMethod(); + + if (!\in_array($method, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE], true)) { + $this->message = 'Invalid query method: ' . $method; + return false; + } + + $cursor = $value->getValue(); + + $validator = new Text(256); + if (!$validator->isValid($cursor)) { + $this->message = 'Invalid cursor: ' . $validator->getDescription(); + return false; + } + + return true; + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_CURSOR; + } +} From 29bbc7299aafa406d5407ced53ed95b94b77d396 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 13:45:46 +0530 Subject: [PATCH 098/115] Enhance URL parameter handling in OpenAPI3 and Swagger2 formats to support aliases for path parameters. --- .../SDK/Specification/Format/OpenAPI3.php | 15 +++++++++++++-- .../SDK/Specification/Format/Swagger2.php | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 962bc8948a..b47d716737 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -755,7 +755,16 @@ class OpenAPI3 extends Format $node['schema']['default'] = $param['default']; } - if (false !== \strpos($url, ':' . $name)) { // Param is in URL path + $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $isPathParam = false; + foreach ($pathAliases as $pathAlias) { + if (false !== \strpos($url, ':' . $pathAlias)) { + $isPathParam = true; + break; + } + } + + if ($isPathParam) { // Param is in URL path (directly or through alias) $node['in'] = 'path'; $temp['parameters'][] = $node; } elseif ($route->getMethod() == 'GET') { // Param is in query @@ -796,7 +805,9 @@ class OpenAPI3 extends Format } } - $url = \str_replace(':' . $name, '{' . $name . '}', $url); + foreach ($pathAliases as $pathAlias) { + $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + } } if (!empty($bodyRequired)) { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index d07d957577..61fa2919c9 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -722,7 +722,16 @@ class Swagger2 extends Format $node['default'] = $param['default']; } - if (\str_contains($url, ':' . $name)) { // Param is in URL path + $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $isPathParam = false; + foreach ($pathAliases as $pathAlias) { + if (\str_contains($url, ':' . $pathAlias)) { + $isPathParam = true; + break; + } + } + + if ($isPathParam) { // Param is in URL path (directly or through alias) $node['in'] = 'path'; $temp['parameters'][] = $node; } elseif ($route->getMethod() == 'GET') { // Param is in query @@ -767,7 +776,9 @@ class Swagger2 extends Format } } - $url = \str_replace(':' . $name, '{' . $name . '}', $url); + foreach ($pathAliases as $pathAlias) { + $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + } } if (!empty($bodyRequired)) { From d59877316386ad0459b1d76b38b3c49c6ae37846 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 8 May 2026 13:50:24 +0530 Subject: [PATCH 099/115] Adjust repository branch cursor typing for analysis --- .../Installations/Repositories/Branches/XList.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php index fe5b8fad5c..fda462159f 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Branches/XList.php @@ -106,17 +106,17 @@ class XList extends Action [ 'limit' => $limit, 'offset' => $offset, - 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, ] = Query::groupByType($queries); + $cursorQuery = \current(Query::getCursorQueries($queries, false)); $limit ??= APP_LIMIT_LIST_DEFAULT; $offset ??= 0; - if ($cursor !== null) { - if (!\is_string($cursor) || $cursor === '') { - throw new Exception(Exception::GENERAL_QUERY_INVALID, 'Invalid cursor query.'); - } + if ($cursorQuery instanceof Query) { + $cursor = $cursorQuery->getValue(); + $cursorDirection = $cursorQuery->getMethod() === Query::TYPE_CURSOR_AFTER + ? Database::CURSOR_AFTER + : Database::CURSOR_BEFORE; $cursorIndex = \array_search($cursor, $branches, true); if ($cursorIndex === false) { From 4b05a6cf8f42836436b98f8dd520d7f6dcd03f76 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 13:52:25 +0530 Subject: [PATCH 100/115] Refactor URL parameter matching in OpenAPI3 and Swagger2 to use preg_match for improved accuracy with path aliases. --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 8 ++++++-- src/Appwrite/SDK/Specification/Format/Swagger2.php | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b47d716737..bea2e112af 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -758,7 +758,7 @@ class OpenAPI3 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (false !== \strpos($url, ':' . $pathAlias)) { + if (\preg_match('/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', $url)) { $isPathParam = true; break; } @@ -806,7 +806,11 @@ class OpenAPI3 extends Format } foreach ($pathAliases as $pathAlias) { - $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + $url = (string)\preg_replace( + '/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', + '{' . $name . '}', + $url + ); } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 61fa2919c9..f8a0395de8 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -725,7 +725,7 @@ class Swagger2 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (\str_contains($url, ':' . $pathAlias)) { + if (\preg_match('/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', $url)) { $isPathParam = true; break; } @@ -777,7 +777,11 @@ class Swagger2 extends Format } foreach ($pathAliases as $pathAlias) { - $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + $url = (string)\preg_replace( + '/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', + '{' . $name . '}', + $url + ); } } From e181954dd183382c475faef367a37c4bef427046 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 14:01:04 +0530 Subject: [PATCH 101/115] removed regex --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 8 ++------ src/Appwrite/SDK/Specification/Format/Swagger2.php | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index bea2e112af..b47d716737 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -758,7 +758,7 @@ class OpenAPI3 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (\preg_match('/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', $url)) { + if (false !== \strpos($url, ':' . $pathAlias)) { $isPathParam = true; break; } @@ -806,11 +806,7 @@ class OpenAPI3 extends Format } foreach ($pathAliases as $pathAlias) { - $url = (string)\preg_replace( - '/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', - '{' . $name . '}', - $url - ); + $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index f8a0395de8..61fa2919c9 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -725,7 +725,7 @@ class Swagger2 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (\preg_match('/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', $url)) { + if (\str_contains($url, ':' . $pathAlias)) { $isPathParam = true; break; } @@ -777,11 +777,7 @@ class Swagger2 extends Format } foreach ($pathAliases as $pathAlias) { - $url = (string)\preg_replace( - '/:' . \preg_quote($pathAlias, '/') . '(?=\/|$)/', - '{' . $name . '}', - $url - ); + $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); } } From 5cfaa0807dcf76b02ddb01ba26040d8d597ef418 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 14:08:27 +0530 Subject: [PATCH 102/115] Refactor URL parameter matching in OpenAPI3 and Swagger2 to improve path parameter detection by checking for trailing characters. --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 14 +++++++++++--- src/Appwrite/SDK/Specification/Format/Swagger2.php | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index b47d716737..c91945fe2b 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -758,9 +758,17 @@ class OpenAPI3 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (false !== \strpos($url, ':' . $pathAlias)) { - $isPathParam = true; - break; + $pathNeedle = ':' . $pathAlias; + $offset = 0; + + while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { + $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; + if ($nextChar === '' || $nextChar === '/') { + $isPathParam = true; + break 2; + } + + $offset = $position + 1; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 61fa2919c9..be0f430a7a 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -725,9 +725,17 @@ class Swagger2 extends Format $pathAliases = [$name, ...($param['aliases'] ?? [])]; $isPathParam = false; foreach ($pathAliases as $pathAlias) { - if (\str_contains($url, ':' . $pathAlias)) { - $isPathParam = true; - break; + $pathNeedle = ':' . $pathAlias; + $offset = 0; + + while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { + $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; + if ($nextChar === '' || $nextChar === '/') { + $isPathParam = true; + break 2; + } + + $offset = $position + 1; } } From 07973dee2d9729c28cb12ede1875376726ae1374 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 14:19:51 +0530 Subject: [PATCH 103/115] Refactor URL parameter replacement logic in OpenAPI3 and Swagger2 to ensure accurate matching of path parameters by checking for trailing characters. --- .../SDK/Specification/Format/OpenAPI3.php | 15 ++++++++++++++- .../SDK/Specification/Format/Swagger2.php | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index c91945fe2b..004be99c5b 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -814,7 +814,20 @@ class OpenAPI3 extends Format } foreach ($pathAliases as $pathAlias) { - $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + $pathNeedle = ':' . $pathAlias; + $replacement = '{' . $name . '}'; + $offset = 0; + + while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { + $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; + if ($nextChar === '' || $nextChar === '/') { + $url = \substr($url, 0, $position) . $replacement . \substr($url, $position + \strlen($pathNeedle)); + $offset = $position + \strlen($replacement); + continue; + } + + $offset = $position + 1; + } } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index be0f430a7a..2d7965df6a 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -785,7 +785,20 @@ class Swagger2 extends Format } foreach ($pathAliases as $pathAlias) { - $url = \str_replace(':' . $pathAlias, '{' . $name . '}', $url); + $pathNeedle = ':' . $pathAlias; + $replacement = '{' . $name . '}'; + $offset = 0; + + while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { + $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; + if ($nextChar === '' || $nextChar === '/') { + $url = \substr($url, 0, $position) . $replacement . \substr($url, $position + \strlen($pathNeedle)); + $offset = $position + \strlen($replacement); + continue; + } + + $offset = $position + 1; + } } } From d303d6f807dd25d400afd9258d779aa84aec4cae Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 14:39:26 +0530 Subject: [PATCH 104/115] Refactor path parameter detection in OpenAPI3 and Swagger2 by utilizing array flipping for improved performance and clarity in matching aliases. --- .../SDK/Specification/Format/OpenAPI3.php | 16 +++++----------- .../SDK/Specification/Format/Swagger2.php | 16 +++++----------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 004be99c5b..4f925b1811 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -756,19 +756,13 @@ class OpenAPI3 extends Format } $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $pathAliasMap = \array_flip($pathAliases); $isPathParam = false; - foreach ($pathAliases as $pathAlias) { - $pathNeedle = ':' . $pathAlias; - $offset = 0; - while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { - $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; - if ($nextChar === '' || $nextChar === '/') { - $isPathParam = true; - break 2; - } - - $offset = $position + 1; + foreach (\explode('/', $url) as $segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $isPathParam = true; + break; } } diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 2d7965df6a..c352154006 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -723,19 +723,13 @@ class Swagger2 extends Format } $pathAliases = [$name, ...($param['aliases'] ?? [])]; + $pathAliasMap = \array_flip($pathAliases); $isPathParam = false; - foreach ($pathAliases as $pathAlias) { - $pathNeedle = ':' . $pathAlias; - $offset = 0; - while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { - $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; - if ($nextChar === '' || $nextChar === '/') { - $isPathParam = true; - break 2; - } - - $offset = $position + 1; + foreach (\explode('/', $url) as $segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $isPathParam = true; + break; } } From 5f6b1dda1a408d3e3faf6f69f885b445bbe94f2c Mon Sep 17 00:00:00 2001 From: Aditya Oberai Date: Fri, 8 May 2026 09:35:38 +0000 Subject: [PATCH 105/115] update composer lock --- composer.lock | 46 ++++++++++++++-------------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/composer.lock b/composer.lock index d356362788..7600e305d2 100644 --- a/composer.lock +++ b/composer.lock @@ -4539,16 +4539,16 @@ }, { "name": "utopia-php/migration", - "version": "1.10.1", + "version": "1.10.2", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827" + "reference": "211d01b90ccab9729029151c6c61f543bd755c2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/759d6d61b327313cbeeeb4ea0c3e2459164b4827", - "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/211d01b90ccab9729029151c6c61f543bd755c2e", + "reference": "211d01b90ccab9729029151c6c61f543bd755c2e", "shasum": "" }, "require": { @@ -4574,25 +4574,7 @@ "Utopia\\Migration\\": "src/Migration" } }, - "autoload-dev": { - "psr-4": { - "Utopia\\Tests\\": "tests/Migration" - } - }, - "scripts": { - "test": [ - "./vendor/bin/phpunit" - ], - "lint": [ - "./vendor/bin/pint --test" - ], - "format": [ - "./vendor/bin/pint" - ], - "check": [ - "./vendor/bin/phpstan analyse --level 3 src tests --memory-limit 2G" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -4605,10 +4587,10 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/migration/tree/1.10.1", - "issues": "https://github.com/utopia-php/migration/issues" + "issues": "https://github.com/utopia-php/migration/issues", + "source": "https://github.com/utopia-php/migration/tree/1.10.2" }, - "time": "2026-05-07T07:23:57+00:00" + "time": "2026-05-08T06:25:47+00:00" }, { "name": "utopia-php/mongo", @@ -5492,16 +5474,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.27.5", + "version": "1.28.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "9faa38b48d422f3da764a719712905c83b3922cb" + "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb", - "reference": "9faa38b48d422f3da764a719712905c83b3922cb", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e363fffd220172c5f1a5032038fa3fdafeeb2dfb", + "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb", "shasum": "" }, "require": { @@ -5537,9 +5519,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.27.5" + "source": "https://github.com/appwrite/sdk-generator/tree/1.28.0" }, - "time": "2026-05-05T12:09:40+00:00" + "time": "2026-05-08T03:37:44+00:00" }, { "name": "brianium/paratest", From 6da8c1cb12a099953216313cd6925750d5ce1aea Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 8 May 2026 15:21:52 +0530 Subject: [PATCH 106/115] updated --- .../SDK/Specification/Format/OpenAPI3.php | 20 ++++++------------- .../SDK/Specification/Format/Swagger2.php | 20 ++++++------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 4f925b1811..c491a3211d 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -807,22 +807,14 @@ class OpenAPI3 extends Format } } - foreach ($pathAliases as $pathAlias) { - $pathNeedle = ':' . $pathAlias; - $replacement = '{' . $name . '}'; - $offset = 0; - - while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { - $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; - if ($nextChar === '' || $nextChar === '/') { - $url = \substr($url, 0, $position) . $replacement . \substr($url, $position + \strlen($pathNeedle)); - $offset = $position + \strlen($replacement); - continue; - } - - $offset = $position + 1; + $segments = \explode('/', $url); + foreach ($segments as &$segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $segment = '{' . $name . '}'; } } + unset($segment); + $url = \implode('/', $segments); } if (!empty($bodyRequired)) { diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index c352154006..1dbba73a94 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -778,22 +778,14 @@ class Swagger2 extends Format } } - foreach ($pathAliases as $pathAlias) { - $pathNeedle = ':' . $pathAlias; - $replacement = '{' . $name . '}'; - $offset = 0; - - while (false !== ($position = \strpos($url, $pathNeedle, $offset))) { - $nextChar = $url[$position + \strlen($pathNeedle)] ?? ''; - if ($nextChar === '' || $nextChar === '/') { - $url = \substr($url, 0, $position) . $replacement . \substr($url, $position + \strlen($pathNeedle)); - $offset = $position + \strlen($replacement); - continue; - } - - $offset = $position + 1; + $segments = \explode('/', $url); + foreach ($segments as &$segment) { + if ($segment !== '' && $segment[0] === ':' && isset($pathAliasMap[\substr($segment, 1)])) { + $segment = '{' . $name . '}'; } } + unset($segment); + $url = \implode('/', $segments); } if (!empty($bodyRequired)) { From fc83b4d9867ea8b711c32f234ab4ecdefd8affe7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 16:15:23 +0530 Subject: [PATCH 107/115] Bump logger dependency --- composer.json | 4 +- composer.lock | 90 ++++++++++--------- .../Modules/Functions/Workers/Screenshots.php | 12 +-- 3 files changed, 55 insertions(+), 51 deletions(-) diff --git a/composer.json b/composer.json index 9a84be6111..55106d33d1 100644 --- a/composer.json +++ b/composer.json @@ -68,11 +68,11 @@ "utopia-php/dns": "1.6.*", "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", - "utopia-php/fetch": "0.5.*", + "utopia-php/fetch": "^1.1", "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", - "utopia-php/logger": "0.6.*", + "utopia-php/logger": "0.8.*", "utopia-php/messaging": "0.22.*", "utopia-php/migration": "1.*", "utopia-php/platform": "0.13.*", diff --git a/composer.lock b/composer.lock index d356362788..f7b443b135 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec2ad489c60f0102f0dfab223b6d1fe4", + "content-hash": "4ef65b015dba97e91f6571b061787653", "packages": [ { "name": "adhocore/jwt", @@ -3411,21 +3411,21 @@ }, { "name": "utopia-php/agents", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/agents.git", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e" + "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e", + "url": "https://api.github.com/repos/utopia-php/agents/zipball/0703f4cae02261e09a1bf0d39a4b1ce649cae634", + "reference": "0703f4cae02261e09a1bf0d39a4b1ce649cae634", "shasum": "" }, "require": { "php": ">=8.3", - "utopia-php/fetch": "0.5.*" + "utopia-php/fetch": "^1.1.0" }, "require-dev": { "laravel/pint": "^1.18", @@ -3458,9 +3458,9 @@ ], "support": { "issues": "https://github.com/utopia-php/agents/issues", - "source": "https://github.com/utopia-php/agents/tree/1.2.1" + "source": "https://github.com/utopia-php/agents/tree/1.2.2" }, - "time": "2026-02-24T06:03:55+00:00" + "time": "2026-05-08T10:38:23+00:00" }, { "name": "utopia-php/analytics", @@ -3510,22 +3510,22 @@ }, { "name": "utopia-php/audit", - "version": "2.2.2", + "version": "2.2.3", "source": { "type": "git", "url": "https://github.com/utopia-php/audit.git", - "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57" + "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/audit/zipball/90886c202e7983999e6b6a8201004d5ab61d4b57", - "reference": "90886c202e7983999e6b6a8201004d5ab61d4b57", + "url": "https://api.github.com/repos/utopia-php/audit/zipball/95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c", + "reference": "95e9961fa286d2fdb6bf3eaa198f21d51bf58d9c", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/database": "5.*", - "utopia-php/fetch": "0.5.*", + "utopia-php/fetch": "^1.1", "utopia-php/validators": "0.2.*" }, "require-dev": { @@ -3553,9 +3553,9 @@ ], "support": { "issues": "https://github.com/utopia-php/audit/issues", - "source": "https://github.com/utopia-php/audit/tree/2.2.2" + "source": "https://github.com/utopia-php/audit/tree/2.2.3" }, - "time": "2026-05-04T06:48:58+00:00" + "time": "2026-05-08T10:38:23+00:00" }, { "name": "utopia-php/auth", @@ -4180,22 +4180,21 @@ }, { "name": "utopia-php/emails", - "version": "0.6.9", + "version": "0.6.10", "source": { "type": "git", "url": "https://github.com/utopia-php/emails.git", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf" + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/emails/zipball/3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", - "reference": "3a59fb392a03a88f5497e5fdb0ea84a252a4dfdf", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/2e397754ce68c2ba918564b9f31d9923c0a90429", + "reference": "2e397754ce68c2ba918564b9f31d9923c0a90429", "shasum": "" }, "require": { "php": ">=8.0", "utopia-php/domains": "^1.0", - "utopia-php/fetch": "^0.5", "utopia-php/validators": "0.*" }, "require-dev": { @@ -4203,7 +4202,8 @@ "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.3", "utopia-php/cli": "^0.22", - "utopia-php/console": "0.*" + "utopia-php/console": "0.*", + "utopia-php/fetch": "^1.1" }, "type": "library", "autoload": { @@ -4235,22 +4235,22 @@ ], "support": { "issues": "https://github.com/utopia-php/emails/issues", - "source": "https://github.com/utopia-php/emails/tree/0.6.9" + "source": "https://github.com/utopia-php/emails/tree/0.6.10" }, - "time": "2026-03-14T13:52:56+00:00" + "time": "2026-05-08T10:16:22+00:00" }, { "name": "utopia-php/fetch", - "version": "0.5.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/fetch.git", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd" + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/fetch/zipball/a96a010e1c273f3888765449687baf58cbc61fcd", - "reference": "a96a010e1c273f3888765449687baf58cbc61fcd", + "url": "https://api.github.com/repos/utopia-php/fetch/zipball/64f2b3a789480f1deb102ce684dac4217d8e98d5", + "reference": "64f2b3a789480f1deb102ce684dac4217d8e98d5", "shasum": "" }, "require": { @@ -4259,7 +4259,8 @@ "require-dev": { "laravel/pint": "^1.5.0", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "swoole/ide-helper": "^6.0" }, "type": "library", "autoload": { @@ -4274,9 +4275,9 @@ "description": "A simple library that provides an interface for making HTTP Requests.", "support": { "issues": "https://github.com/utopia-php/fetch/issues", - "source": "https://github.com/utopia-php/fetch/tree/0.5.1" + "source": "https://github.com/utopia-php/fetch/tree/1.1.2" }, - "time": "2025-12-18T16:25:10+00:00" + "time": "2026-04-29T11:19:19+00:00" }, { "name": "utopia-php/http", @@ -4434,20 +4435,21 @@ }, { "name": "utopia-php/logger", - "version": "0.6.2", + "version": "0.8.0", "source": { "type": "git", "url": "https://github.com/utopia-php/logger.git", - "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d" + "reference": "132236c42222cd614cb882938a48f8729ef3118b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/logger/zipball/25b5bd2ad8bb51292f76332faa7034644fd0941d", - "reference": "25b5bd2ad8bb51292f76332faa7034644fd0941d", + "url": "https://api.github.com/repos/utopia-php/logger/zipball/132236c42222cd614cb882938a48f8729ef3118b", + "reference": "132236c42222cd614cb882938a48f8729ef3118b", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.1", + "utopia-php/fetch": "^1.1" }, "require-dev": { "laravel/pint": "1.2.*", @@ -4482,9 +4484,9 @@ ], "support": { "issues": "https://github.com/utopia-php/logger/issues", - "source": "https://github.com/utopia-php/logger/tree/0.6.2" + "source": "https://github.com/utopia-php/logger/tree/0.8.0" }, - "time": "2024-10-14T16:02:49+00:00" + "time": "2026-05-05T06:04:27+00:00" }, { "name": "utopia-php/messaging", @@ -5254,23 +5256,23 @@ }, { "name": "utopia-php/vcs", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3" + "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/44a84ab52b42fc12f812b4d7331286b519d39db3", - "reference": "44a84ab52b42fc12f812b4d7331286b519d39db3", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03ccd12b75d67d29094eb760b468fddde4b6b5e5", + "reference": "03ccd12b75d67d29094eb760b468fddde4b6b5e5", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", "utopia-php/cache": "1.0.*", - "utopia-php/fetch": "0.5.*" + "utopia-php/fetch": "^1.1" }, "require-dev": { "laravel/pint": "1.*.*", @@ -5297,9 +5299,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/3.2.0" + "source": "https://github.com/utopia-php/vcs/tree/3.2.1" }, - "time": "2026-04-08T16:00:31+00:00" + "time": "2026-05-08T10:13:53+00:00" }, { "name": "utopia-php/websocket", diff --git a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php index 7d1cdc4980..c766f73929 100644 --- a/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php +++ b/src/Appwrite/Platform/Modules/Functions/Workers/Screenshots.php @@ -109,9 +109,7 @@ class Screenshots extends Action throw new \Exception("Rule for deployment not found"); } - $client = new FetchClient(); - $client->setTimeout(\intval($site->getAttribute('timeout', '15')) * 1000); - $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); + $timeout = \intval($site->getAttribute('timeout', '15')) * 1000; $bucket = $dbForPlatform->getDocument('buckets', 'screenshots'); @@ -162,8 +160,8 @@ class Screenshots extends Action ]); $screenshotError = null; - $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $client, &$screenshotError) { - return function () use ($key, $configs, $apiKey, $site, $client, &$screenshotError) { + $screenshots = batch(\array_map(function ($key) use ($configs, $apiKey, $site, $timeout, &$screenshotError) { + return function () use ($key, $configs, $apiKey, $site, $timeout, &$screenshotError) { try { $config = $configs[$key]; @@ -179,6 +177,10 @@ class Screenshots extends Action } $browserEndpoint = System::getEnv('_APP_BROWSER_HOST', 'http://appwrite-browser:3000/v1'); + $client = new FetchClient(); + $client->setTimeout($timeout); + $client->addHeader('content-type', FetchClient::CONTENT_TYPE_APPLICATION_JSON); + $fetchResponse = $client->fetch( url: $browserEndpoint . '/screenshots', method: 'POST', From 9100e06bbe7af1f2f2d7e8ce90f355f33dab619c Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Fri, 8 May 2026 15:04:54 +0400 Subject: [PATCH 108/115] perf(storage): skip no-op imagick transforms and add transform value spans - Only call crop() when dimensions > 0 or gravity differs from center - Only call setOpacity() when opacity !== 1.0 (was always called due to !empty(1.0)) - Only call setBorder() when borderWidth > 0 - Only call setBorderRadius() when borderRadius > 0 - Only call setRotation() when rotation !== 0 - Add Span::add() with actual values inside each transform condition - Leave output() unconditional (format conversion always applies) --- .../Http/Buckets/Files/Preview/Get.php | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 4fa5006db8..164d42d1b8 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -241,28 +241,43 @@ class Get extends Action throw new Exception(Exception::STORAGE_FILE_TYPE_UNSUPPORTED, $e->getMessage()); } - $image->crop((int) $width, (int) $height, $gravity); + if ($width > 0 || $height > 0 || $gravity !== Image::GRAVITY_CENTER) { + Span::add('storage.transform.crop.width', $width); + Span::add('storage.transform.crop.height', $height); + Span::add('storage.transform.crop.gravity', $gravity); + $image->crop($width, $height, $gravity); + } - if (!empty($opacity)) { + if ($opacity !== 1.0) { + Span::add('storage.transform.opacity', $opacity); $image->setOpacity($opacity); } if (!empty($background)) { + Span::add('storage.transform.background', $background); $image->setBackground('#' . $background); } - if (!empty($borderWidth)) { + if ($borderWidth > 0) { + Span::add('storage.transform.border.width', $borderWidth); + Span::add('storage.transform.border.color', $borderColor); $image->setBorder($borderWidth, '#' . $borderColor); } - if (!empty($borderRadius)) { + if ($borderRadius > 0) { + Span::add('storage.transform.borderRadius', $borderRadius); $image->setBorderRadius($borderRadius); } - if (!empty($rotation)) { + if ($rotation !== 0) { + Span::add('storage.transform.rotation', $rotation); $image->setRotation(($rotation + 360) % 360); } + if ($quality !== -1) { + Span::add('storage.transform.quality', $quality); + } + $data = $image->output($output, $quality); $renderingTime = \microtime(true) - $startTime - $downloadTime - $decryptionTime - $decompressionTime; From 929586927925e29a6acce9affed065c553803ba5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 8 May 2026 17:35:50 +0530 Subject: [PATCH 109/115] Fix GraphQL preview test assertions --- tests/e2e/Services/GraphQL/Base.php | 20 +++++++++++++++++-- .../Services/GraphQL/StorageClientTest.php | 2 +- .../Services/GraphQL/StorageServerTest.php | 2 +- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index c42679018e..9010490cc9 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -4,6 +4,7 @@ namespace Tests\E2E\Services\GraphQL; use CURLFile; use Utopia\Console; +use Utopia\Image\Image; trait Base { @@ -516,6 +517,21 @@ trait Base } '; + protected function assertFilePreviewResponse(array $file): void + { + $this->assertEquals(200, $file['headers']['status-code']); + $this->assertEquals('image/png', $file['headers']['content-type']); + $this->assertNotEmpty($file['body']); + + $image = new Image($file['body']); + $dimensions = \getimagesizefromstring($file['body']); + + $this->assertNotEmpty($image->output('png')); + $this->assertIsArray($dimensions); + $this->assertEquals(100, $dimensions[0]); + $this->assertEquals(100, $dimensions[1]); + } + public function getQuery(string $name): string { switch ($name) { @@ -2388,8 +2404,8 @@ trait Base } }'; case self::GET_FILE_PREVIEW: - return 'query getFilePreview($bucketId: String!, $fileId: String!) { - storageGetFilePreview(bucketId: $bucketId, fileId: $fileId) { + return 'query getFilePreview($bucketId: String!, $fileId: String!, $width: Int, $height: Int) { + storageGetFilePreview(bucketId: $bucketId, fileId: $fileId, width: $width, height: $height) { status } }'; diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index dd89819c34..9cdf523a0a 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -200,7 +200,7 @@ class StorageClientTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - $this->assertEquals(46719, \strlen($file['body'])); + $this->assertFilePreviewResponse($file); return $file; } diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index 1377ef9207..7808c50be6 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -262,7 +262,7 @@ class StorageServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - $this->assertEquals(46719, \strlen($file['body'])); + $this->assertFilePreviewResponse($file); return $file; } From 10e4341db2b5219f0314f57334bde16b1378abe4 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 8 May 2026 19:15:17 +0530 Subject: [PATCH 110/115] Support branch query validators in SDK generation --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 1 + src/Appwrite/SDK/Specification/Format/Swagger2.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index c491a3211d..f69ec3972a 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -524,6 +524,7 @@ class OpenAPI3 extends Format case \Appwrite\Utopia\Database\Validator\Queries\Identities::class: case \Appwrite\Utopia\Database\Validator\Queries\Indexes::class: case \Appwrite\Utopia\Database\Validator\Queries\Installations::class: + case \Appwrite\Utopia\Database\Validator\Queries\Branches::class: case \Appwrite\Utopia\Database\Validator\Queries\Memberships::class: case \Appwrite\Utopia\Database\Validator\Queries\Messages::class: case \Appwrite\Utopia\Database\Validator\Queries\Migrations::class: diff --git a/src/Appwrite/SDK/Specification/Format/Swagger2.php b/src/Appwrite/SDK/Specification/Format/Swagger2.php index 1dbba73a94..52e33bcc27 100644 --- a/src/Appwrite/SDK/Specification/Format/Swagger2.php +++ b/src/Appwrite/SDK/Specification/Format/Swagger2.php @@ -511,6 +511,7 @@ class Swagger2 extends Format case \Utopia\Database\Validator\Queries::class: case \Utopia\Database\Validator\Queries\Document::class: case \Utopia\Database\Validator\Queries\Documents::class: + case \Appwrite\Utopia\Database\Validator\Queries\Branches::class: case \Appwrite\Utopia\Database\Validator\Queries\Columns::class: case \Appwrite\Utopia\Database\Validator\Queries\Tables::class: $node['type'] = 'array'; From 6ee2196fae1f0b27c694b65acfc2320beb91d556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 9 May 2026 09:54:54 +0200 Subject: [PATCH 111/115] Fix git hint regnerating nonstop --- src/Appwrite/Vcs/Comment.php | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php index 4dc0174e50..8741ecff6c 100644 --- a/src/Appwrite/Vcs/Comment.php +++ b/src/Appwrite/Vcs/Comment.php @@ -50,6 +50,8 @@ class Comment protected string $statePrefix = '[appwrite]: #'; + protected ?string $tip = null; + /** * @var mixed[] $builds */ @@ -81,7 +83,14 @@ class Comment public function generateComment(): string { - $json = \json_encode($this->builds); + if ($this->tip === null) { + $this->tip = $this->tips[\array_rand($this->tips)]; + } + + $json = \json_encode([ + 'builds' => $this->builds, + 'tip' => $this->tip, + ]); $text = $this->statePrefix . \base64_encode($json) . "\n\n"; @@ -226,8 +235,7 @@ class Comment $i++; } - $tip = $this->tips[array_rand($this->tips)]; - $text .= "\n
\n\n> [!TIP]\n> $tip\n\n"; + $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n"; return $text; } @@ -252,8 +260,15 @@ class Comment $json = \base64_decode($state); - $builds = \json_decode($json, true); - $this->builds = \is_array($builds) ? $builds : []; + $data = \json_decode($json, true); + + if (\is_array($data) && \array_key_exists('builds', $data)) { + $this->builds = \is_array($data['builds']) ? $data['builds'] : []; + $this->tip = $data['tip'] ?? null; + } else { + // Backward compatibility with old state format (builds array only) + $this->builds = \is_array($data) ? $data : []; + } return $this; } From 43777ee6d99b79cc940c98be080247c8b3cd46d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 9 May 2026 10:16:19 +0200 Subject: [PATCH 112/115] Add unit tests for github hints --- tests/unit/Vcs/CommentTest.php | 144 +++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/unit/Vcs/CommentTest.php diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php new file mode 100644 index 0000000000..c6f69e2f1b --- /dev/null +++ b/tests/unit/Vcs/CommentTest.php @@ -0,0 +1,144 @@ + 'localhost']); + $comment->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func1', 'name' => 'Test Function']), + 'function', + 'ready', + 'dep1', + ['type' => 'logs'], + '' + ); + + $first = $comment->generateComment(); + $firstTip = $this->extractTip($first); + + $this->assertNotNull($firstTip); + $this->assertNotEmpty($firstTip); + + $second = $comment->generateComment(); + $secondTip = $this->extractTip($second); + + $this->assertEquals($firstTip, $secondTip); + } + + public function testTipIsRestoredFromParsedComment(): void + { + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func1', 'name' => 'Test Function']), + 'function', + 'ready', + 'dep1', + ['type' => 'logs'], + '' + ); + + $original = $comment->generateComment(); + $originalTip = $this->extractTip($original); + + $parsed = new Comment(['consoleHostname' => 'localhost']); + $parsed->parseComment($original); + $parsed->addBuild( + new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']), + new Document(['$id' => 'func2', 'name' => 'Another Function']), + 'function', + 'building', + 'dep2', + ['type' => 'logs'], + '' + ); + + $regenerated = $parsed->generateComment(); + $regeneratedTip = $this->extractTip($regenerated); + + $this->assertEquals($originalTip, $regeneratedTip); + } + + public function testBackwardCompatibilityWithOldStateFormat(): void + { + $oldBuilds = [ + 'project1_func1' => [ + 'projectName' => 'Test Project', + 'projectId' => 'project1', + 'region' => 'default', + 'resourceName' => 'Test Function', + 'resourceId' => 'func1', + 'resourceType' => 'function', + 'buildStatus' => 'ready', + 'deploymentId' => 'dep1', + 'action' => ['type' => 'logs'], + 'previewUrl' => '', + ], + ]; + + $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n"; + $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n"; + + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->parseComment($oldState); + + $new = $comment->generateComment(); + $newTip = $this->extractTip($new); + + $this->assertNotNull($newTip); + $this->assertNotEquals('Old tip that should be ignored', $newTip); + } + + public function testParseOldStateFormatWithOnlyBuilds(): void + { + $oldBuilds = [ + 'project1_func1' => [ + 'projectName' => 'Test Project', + 'projectId' => 'project1', + 'region' => 'default', + 'resourceName' => 'Test Function', + 'resourceId' => 'func1', + 'resourceType' => 'function', + 'buildStatus' => 'ready', + 'deploymentId' => 'dep1', + 'action' => ['type' => 'logs'], + 'previewUrl' => '', + ], + ]; + + $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n"; + + $comment = new Comment(['consoleHostname' => 'localhost']); + $comment->parseComment($state); + + $this->assertEquals(false, $comment->isEmpty()); + + $first = $comment->generateComment(); + $firstTip = $this->extractTip($first); + + $this->assertNotNull($firstTip); + $this->assertNotEmpty($firstTip); + + $second = $comment->generateComment(); + $secondTip = $this->extractTip($second); + + $this->assertEquals($firstTip, $secondTip); + } + + private function extractTip(string $comment): ?string + { + if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) { + return $matches[1]; + } + + return null; + } +} From 76a41d70b0150b59081e0087c8954e16bfad6a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 9 May 2026 10:51:46 +0200 Subject: [PATCH 113/115] Dual read for google oauth secret Will allow future support for more params --- src/Appwrite/Auth/OAuth2/Google.php | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php index 79894c2422..0ee495df7d 100644 --- a/src/Appwrite/Auth/OAuth2/Google.php +++ b/src/Appwrite/Auth/OAuth2/Google.php @@ -72,7 +72,7 @@ class Google extends OAuth2 'https://oauth2.googleapis.com/token?' . \http_build_query([ 'code' => $code, 'client_id' => $this->appID, - 'client_secret' => $this->appSecret, + 'client_secret' => $this->getClientSecret(), 'redirect_uri' => $this->callback, 'scope' => null, 'grant_type' => 'authorization_code' @@ -95,7 +95,7 @@ class Google extends OAuth2 'https://oauth2.googleapis.com/token?' . \http_build_query([ 'refresh_token' => $refreshToken, 'client_id' => $this->appID, - 'client_secret' => $this->appSecret, + 'client_secret' => $this->getClientSecret(), 'grant_type' => 'refresh_token' ]) ), true); @@ -177,4 +177,33 @@ class Google extends OAuth2 return $this->user; } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Decode the JSON stored in appSecret. + * Falls back to treating the raw string as the client secret for backwards compatibility. + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + return ['clientSecret' => $this->appSecret]; + } + + return $secret; + } } From a5ddc465e6112fddc756a9532d71bb4ec485158e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 9 May 2026 12:53:11 +0200 Subject: [PATCH 114/115] PR review fixes --- tests/unit/Vcs/CommentTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php index c6f69e2f1b..29973089c6 100644 --- a/tests/unit/Vcs/CommentTest.php +++ b/tests/unit/Vcs/CommentTest.php @@ -95,6 +95,7 @@ class CommentTest extends TestCase $this->assertNotNull($newTip); $this->assertNotEquals('Old tip that should be ignored', $newTip); + $this->assertContains($newTip, $this->getTips()); } public function testParseOldStateFormatWithOnlyBuilds(): void @@ -126,6 +127,7 @@ class CommentTest extends TestCase $this->assertNotNull($firstTip); $this->assertNotEmpty($firstTip); + $this->assertContains($firstTip, $this->getTips()); $second = $comment->generateComment(); $secondTip = $this->extractTip($second); @@ -141,4 +143,12 @@ class CommentTest extends TestCase return null; } + + private function getTips(): array + { + $reflection = new \ReflectionClass(Comment::class); + $property = $reflection->getProperty('tips'); + + return $property->getValue(new Comment(['consoleHostname' => 'localhost'])); + } } From 0e939ea9d747c1bf38ace4555623acc92927e010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Sat, 9 May 2026 12:58:47 +0200 Subject: [PATCH 115/115] PR review fixes --- src/Appwrite/Auth/OAuth2/Google.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php index 0ee495df7d..6028bd109b 100644 --- a/src/Appwrite/Auth/OAuth2/Google.php +++ b/src/Appwrite/Auth/OAuth2/Google.php @@ -187,7 +187,7 @@ class Google extends OAuth2 { $secret = $this->getAppSecret(); - return $secret['clientSecret'] ?? ''; + return $secret['clientSecret'] ?? $this->appSecret; } /** @@ -204,6 +204,10 @@ class Google extends OAuth2 return ['clientSecret' => $this->appSecret]; } + if (!\is_array($secret)) { + return ['clientSecret' => $this->appSecret]; + } + return $secret; } }